diff --git a/.clippy.toml b/.clippy.toml index 20d3251..0a3de0a 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -6,3 +6,10 @@ await-holding-invalid-types = [ "dioxus_signals::WriteLock", { path = "dioxus_signals::WriteLock", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." }, ] + +disallowed-methods = [ + { path = "once_cell::unsync::OnceCell::get_or_init", reason = "use `std::cell::OnceCell` instead, unless you need get_or_try_init in which case #[expect] this lint" }, + { path = "once_cell::sync::OnceCell::get_or_init", reason = "use `std::sync::OnceLock` instead, unless you need get_or_try_init in which case #[expect] this lint" }, + { path = "once_cell::unsync::Lazy::new", reason = "use `std::cell::LazyCell` instead, unless you need into_value" }, + { path = "once_cell::sync::Lazy::new", reason = "use `std::sync::LazyLock` instead, unless you need into_value" }, +] diff --git a/crates/pinakes-core/src/export.rs b/crates/pinakes-core/src/export.rs index 50542b9..f50ec38 100644 --- a/crates/pinakes-core/src/export.rs +++ b/crates/pinakes-core/src/export.rs @@ -42,7 +42,9 @@ pub async fn export_library( match format { ExportFormat::Json => { let json = serde_json::to_string_pretty(&items).map_err(|e| { - crate::error::PinakesError::Config(format!("json serialize: {e}")) + crate::error::PinakesError::Serialization(format!( + "json serialize: {e}" + )) })?; std::fs::write(destination, json)?; }, diff --git a/crates/pinakes-core/src/import.rs b/crates/pinakes-core/src/import.rs index 6d3c657..27046e2 100644 --- a/crates/pinakes-core/src/import.rs +++ b/crates/pinakes-core/src/import.rs @@ -498,10 +498,14 @@ fn collect_import_result( tracing::warn!(path = %path.display(), error = %e, "failed to import file"); results.push(Err(e)); }, - Err(e) => { - tracing::error!(error = %e, "import task panicked"); + Err(join_err) => { + if join_err.is_panic() { + tracing::error!(error = %join_err, "import task panicked"); + } else { + tracing::warn!(error = %join_err, "import task was cancelled"); + } results.push(Err(PinakesError::InvalidOperation(format!( - "import task panicked: {e}" + "import task failed: {join_err}" )))); }, } diff --git a/crates/pinakes-core/src/metadata/document.rs b/crates/pinakes-core/src/metadata/document.rs index f284c51..4994020 100644 --- a/crates/pinakes-core/src/metadata/document.rs +++ b/crates/pinakes-core/src/metadata/document.rs @@ -32,7 +32,7 @@ fn extract_pdf(path: &Path) -> Result { .map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?; let mut meta = ExtractedMetadata::default(); - let mut book_meta = crate::model::ExtractedBookMetadata::default(); + let mut book_meta = crate::model::BookMetadata::default(); // Find the Info dictionary via the trailer if let Ok(info_ref) = doc.trailer.get(b"Info") { @@ -145,7 +145,7 @@ fn extract_epub(path: &Path) -> Result { ..Default::default() }; - let mut book_meta = crate::model::ExtractedBookMetadata::default(); + let mut book_meta = crate::model::BookMetadata::default(); // Extract basic metadata if let Some(lang) = doc.mdata("language") { diff --git a/crates/pinakes-core/src/metadata/mod.rs b/crates/pinakes-core/src/metadata/mod.rs index ddb601e..0ea4da3 100644 --- a/crates/pinakes-core/src/metadata/mod.rs +++ b/crates/pinakes-core/src/metadata/mod.rs @@ -6,11 +6,7 @@ pub mod video; use std::{collections::HashMap, path::Path}; -use crate::{ - error::Result, - media_type::MediaType, - model::ExtractedBookMetadata, -}; +use crate::{error::Result, media_type::MediaType, model::BookMetadata}; #[derive(Debug, Clone, Default)] pub struct ExtractedMetadata { @@ -22,7 +18,7 @@ pub struct ExtractedMetadata { pub duration_secs: Option, pub description: Option, pub extra: HashMap, - pub book_metadata: Option, + pub book_metadata: Option, // Photo-specific metadata pub date_taken: Option>, diff --git a/crates/pinakes-core/src/model.rs b/crates/pinakes-core/src/model.rs index cedf0ef..19d6e8e 100644 --- a/crates/pinakes-core/src/model.rs +++ b/crates/pinakes-core/src/model.rs @@ -417,6 +417,10 @@ pub struct SavedSearch { // Book Management Types /// Metadata for book-type media. +/// +/// Used both as a DB record (with populated `media_id`, `created_at`, +/// `updated_at`) and as an extraction result (with placeholder values for +/// those fields when the record has not yet been persisted). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BookMetadata { pub media_id: MediaId, @@ -435,6 +439,28 @@ pub struct BookMetadata { pub updated_at: DateTime, } +impl Default for BookMetadata { + fn default() -> Self { + let now = Utc::now(); + Self { + media_id: MediaId(uuid::Uuid::nil()), + isbn: None, + isbn13: None, + publisher: None, + language: None, + page_count: None, + publication_date: None, + series_name: None, + series_index: None, + format: None, + authors: Vec::new(), + identifiers: HashMap::new(), + created_at: now, + updated_at: now, + } + } +} + /// Information about a book author. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AuthorInfo { @@ -476,22 +502,6 @@ impl AuthorInfo { } } -/// Book metadata extracted from files (without database-specific fields) -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ExtractedBookMetadata { - pub isbn: Option, - pub isbn13: Option, - pub publisher: Option, - pub language: Option, - pub page_count: Option, - pub publication_date: Option, - pub series_name: Option, - pub series_index: Option, - pub format: Option, - pub authors: Vec, - pub identifiers: HashMap>, -} - /// Reading progress for a book. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReadingProgress { diff --git a/crates/pinakes-core/src/plugin/mod.rs b/crates/pinakes-core/src/plugin/mod.rs index b419e0d..e43e930 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -602,33 +602,107 @@ impl PluginManager { /// List all UI pages provided by loaded plugins. /// /// Returns a vector of `(plugin_id, page)` tuples for all enabled plugins - /// that provide pages in their manifests. + /// that provide pages in their manifests. Both inline and file-referenced + /// page entries are resolved. pub async fn list_ui_pages( &self, ) -> Vec<(String, pinakes_plugin_api::UiPage)> { + self + .list_ui_pages_with_endpoints() + .await + .into_iter() + .map(|(id, page, _)| (id, page)) + .collect() + } + + /// List all UI pages provided by loaded plugins, including each plugin's + /// declared endpoint allowlist. + /// + /// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples. The + /// `allowed_endpoints` list mirrors the `required_endpoints` field from the + /// plugin manifest's `[ui]` section. + pub async fn list_ui_pages_with_endpoints( + &self, + ) -> Vec<(String, pinakes_plugin_api::UiPage, Vec)> { let registry = self.registry.read().await; let mut pages = Vec::new(); for plugin in registry.list_all() { if !plugin.enabled { continue; } - for entry in &plugin.manifest.ui.pages { - let page = match entry { - pinakes_plugin_api::manifest::UiPageEntry::Inline(page) => { - (**page).clone() - }, - pinakes_plugin_api::manifest::UiPageEntry::File { .. } => { - // File-referenced pages require a base path to resolve; - // skip them here as they should have been loaded at startup. - continue; - }, - }; - pages.push((plugin.id.clone(), page)); + let allowed = plugin.manifest.ui.required_endpoints.clone(); + let plugin_dir = plugin + .manifest_path + .as_ref() + .and_then(|p| p.parent()) + .map(std::path::Path::to_path_buf); + let Some(plugin_dir) = plugin_dir else { + for entry in &plugin.manifest.ui.pages { + if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry + { + pages.push((plugin.id.clone(), (**page).clone(), allowed.clone())); + } + } + continue; + }; + match plugin.manifest.load_ui_pages(&plugin_dir) { + Ok(loaded) => { + for page in loaded { + pages.push((plugin.id.clone(), page, allowed.clone())); + } + }, + Err(e) => { + tracing::warn!( + "Failed to load UI pages for plugin '{}': {e}", + plugin.id + ); + }, } } pages } + /// Collect CSS custom property overrides declared by all enabled plugins. + /// + /// When multiple plugins declare the same property name, later-loaded plugins + /// overwrite earlier ones. Returns an empty map if no plugins are loaded or + /// none declare theme extensions. + pub async fn list_ui_theme_extensions( + &self, + ) -> std::collections::HashMap { + let registry = self.registry.read().await; + let mut merged = std::collections::HashMap::new(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + for (k, v) in &plugin.manifest.ui.theme_extensions { + merged.insert(k.clone(), v.clone()); + } + } + merged + } + + /// List all UI widgets provided by loaded plugins. + /// + /// Returns a vector of `(plugin_id, widget)` tuples for all enabled plugins + /// that provide widgets in their manifests. + pub async fn list_ui_widgets( + &self, + ) -> Vec<(String, pinakes_plugin_api::UiWidget)> { + let registry = self.registry.read().await; + let mut widgets = Vec::new(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + for widget in &plugin.manifest.ui.widgets { + widgets.push((plugin.id.clone(), widget.clone())); + } + } + widgets + } + /// Check if a plugin is loaded and enabled pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool { let registry = self.registry.read().await; @@ -746,6 +820,7 @@ mod tests { }, capabilities: Default::default(), config: Default::default(), + ui: Default::default(), } } diff --git a/crates/pinakes-core/src/plugin/registry.rs b/crates/pinakes-core/src/plugin/registry.rs index afa09b1..a773164 100644 --- a/crates/pinakes-core/src/plugin/registry.rs +++ b/crates/pinakes-core/src/plugin/registry.rs @@ -131,7 +131,7 @@ impl PluginRegistry { self .plugins .values() - .filter(|p| p.manifest.plugin.kind.contains(&kind.to_string())) + .filter(|p| p.manifest.plugin.kind.iter().any(|k| k == kind)) .collect() } @@ -182,6 +182,7 @@ mod tests { }, capabilities: ManifestCapabilities::default(), config: HashMap::new(), + ui: Default::default(), }; RegisteredPlugin { diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index e0caeee..f9d2a43 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -4295,6 +4295,11 @@ impl StorageBackend for PostgresBackend { &self, metadata: &crate::model::BookMetadata, ) -> Result<()> { + if metadata.media_id.0.is_nil() { + return Err(PinakesError::Database( + "upsert_book_metadata: media_id must not be nil".to_string(), + )); + } let mut client = self .pool .get() diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index c377d9e..847256a 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -1116,7 +1116,8 @@ impl StorageBackend for SqliteBackend { parent_id.map(|p| p.to_string()), now.to_rfc3339(), ], - )?; + ) + .map_err(crate::error::db_ctx("create_tag", &name))?; drop(db); Tag { id, @@ -1192,7 +1193,8 @@ impl StorageBackend for SqliteBackend { .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; let changed = db - .execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()])?; + .execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()]) + .map_err(crate::error::db_ctx("delete_tag", id))?; drop(db); if changed == 0 { return Err(PinakesError::TagNotFound(id.to_string())); @@ -1214,7 +1216,11 @@ impl StorageBackend for SqliteBackend { db.execute( "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", params![media_id.0.to_string(), tag_id.to_string()], - )?; + ) + .map_err(crate::error::db_ctx( + "tag_media", + format!("{media_id} x {tag_id}"), + ))?; } Ok(()) }) @@ -1232,7 +1238,11 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2", params![media_id.0.to_string(), tag_id.to_string()], - )?; + ) + .map_err(crate::error::db_ctx( + "untag_media", + format!("{media_id} x {tag_id}"), + ))?; } Ok(()) }) @@ -1323,7 +1333,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339(), ], - )?; + ) + .map_err(crate::error::db_ctx("create_collection", &name))?; drop(db); Collection { id, @@ -1406,7 +1417,8 @@ impl StorageBackend for SqliteBackend { let changed = db .execute("DELETE FROM collections WHERE id = ?1", params![ id.to_string() - ])?; + ]) + .map_err(crate::error::db_ctx("delete_collection", id))?; drop(db); if changed == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); @@ -1440,7 +1452,11 @@ impl StorageBackend for SqliteBackend { position, now.to_rfc3339(), ], - )?; + ) + .map_err(crate::error::db_ctx( + "add_to_collection", + format!("{collection_id} <- {media_id}"), + ))?; } Ok(()) }) @@ -1463,7 +1479,11 @@ impl StorageBackend for SqliteBackend { "DELETE FROM collection_members WHERE collection_id = ?1 AND \ media_id = ?2", params![collection_id.to_string(), media_id.0.to_string()], - )?; + ) + .map_err(crate::error::db_ctx( + "remove_from_collection", + format!("{collection_id} <- {media_id}"), + ))?; } Ok(()) }) @@ -1863,20 +1883,29 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let tx = db.unchecked_transaction()?; + let ctx = format!("{} media x {} tags", media_ids.len(), tag_ids.len()); + let tx = db + .unchecked_transaction() + .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; // Prepare statement once for reuse - let mut stmt = tx.prepare_cached( - "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", - )?; + let mut stmt = tx + .prepare_cached( + "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, \ + ?2)", + ) + .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; let mut count = 0u64; for mid in &media_ids { for tid in &tag_ids { - let rows = stmt.execute(params![mid, tid])?; + let rows = stmt + .execute(params![mid, tid]) + .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; count += rows as u64; // INSERT OR IGNORE: rows=1 if new, 0 if existed } } drop(stmt); - tx.commit()?; + tx.commit() + .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; count }; Ok(count) @@ -2695,7 +2724,7 @@ impl StorageBackend for SqliteBackend { let id_str = id.0.to_string(); let now = chrono::Utc::now(); let role_str = serde_json::to_string(&role).map_err(|e| { - PinakesError::Database(format!("failed to serialize role: {e}")) + PinakesError::Serialization(format!("failed to serialize role: {e}")) })?; tx.execute( @@ -2714,7 +2743,7 @@ impl StorageBackend for SqliteBackend { let user_profile = if let Some(prof) = profile.clone() { let prefs_json = serde_json::to_string(&prof.preferences).map_err(|e| { - PinakesError::Database(format!( + PinakesError::Serialization(format!( "failed to serialize preferences: {e}" )) })?; @@ -2796,7 +2825,9 @@ impl StorageBackend for SqliteBackend { if let Some(ref r) = role { updates.push("role = ?"); let role_str = serde_json::to_string(r).map_err(|e| { - PinakesError::Database(format!("failed to serialize role: {e}")) + PinakesError::Serialization(format!( + "failed to serialize role: {e}" + )) })?; params.push(Box::new(role_str)); } @@ -2814,7 +2845,7 @@ impl StorageBackend for SqliteBackend { if let Some(prof) = profile { let prefs_json = serde_json::to_string(&prof.preferences).map_err(|e| { - PinakesError::Database(format!( + PinakesError::Serialization(format!( "failed to serialize preferences: {e}" )) })?; @@ -2966,7 +2997,9 @@ impl StorageBackend for SqliteBackend { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; let perm_str = serde_json::to_string(&permission).map_err(|e| { - PinakesError::Database(format!("failed to serialize permission: {e}")) + PinakesError::Serialization(format!( + "failed to serialize permission: {e}" + )) })?; let now = chrono::Utc::now(); db.execute( @@ -5055,6 +5088,11 @@ impl StorageBackend for SqliteBackend { &self, metadata: &crate::model::BookMetadata, ) -> Result<()> { + if metadata.media_id.0.is_nil() { + return Err(PinakesError::InvalidOperation( + "upsert_book_metadata: media_id must not be nil".to_string(), + )); + } let conn = Arc::clone(&self.conn); let media_id_str = metadata.media_id.to_string(); let isbn = metadata.isbn.clone(); diff --git a/crates/pinakes-core/src/thumbnail.rs b/crates/pinakes-core/src/thumbnail.rs index 1656e2f..7e3b799 100644 --- a/crates/pinakes-core/src/thumbnail.rs +++ b/crates/pinakes-core/src/thumbnail.rs @@ -27,7 +27,10 @@ impl TempFileGuard { impl Drop for TempFileGuard { fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); + if self.0.exists() + && let Err(e) = std::fs::remove_file(&self.0) { + warn!("failed to clean up temp file {}: {e}", self.0.display()); + } } } diff --git a/crates/pinakes-core/tests/integration.rs b/crates/pinakes-core/tests/integration.rs index da1035a..927d012 100644 --- a/crates/pinakes-core/tests/integration.rs +++ b/crates/pinakes-core/tests/integration.rs @@ -927,3 +927,124 @@ async fn test_transcode_sessions() { .unwrap(); assert_eq!(cleaned, 1); } + +#[tokio::test] +async fn test_batch_update_media_empty() { + let storage = setup().await; + + // Empty ID slice must return 0 without error. + let count = storage + .batch_update_media(&[], Some("title"), None, None, None, None, None) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn test_batch_update_media_no_fields() { + let storage = setup().await; + let item = make_test_media("bum_nofield"); + storage.insert_media(&item).await.unwrap(); + + // No fields to change: implementation returns 0 (only updated_at would + // shift, but the bulk path short-circuits when no real fields are given). + let count = storage + .batch_update_media(&[item.id], None, None, None, None, None, None) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn test_batch_update_media_single_field() { + let storage = setup().await; + let item = make_test_media("bum_single"); + storage.insert_media(&item).await.unwrap(); + + let count = storage + .batch_update_media( + &[item.id], + Some("Bulk Title"), + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(count, 1); + + let fetched = storage.get_media(item.id).await.unwrap(); + assert_eq!(fetched.title.as_deref(), Some("Bulk Title")); + // Fields not touched must remain unchanged. + assert_eq!(fetched.artist.as_deref(), Some("Test Artist")); +} + +#[tokio::test] +async fn test_batch_update_media_multiple_items() { + let storage = setup().await; + + let item_a = make_test_media("bum_multi_a"); + let item_b = make_test_media("bum_multi_b"); + let item_c = make_test_media("bum_multi_c"); + storage.insert_media(&item_a).await.unwrap(); + storage.insert_media(&item_b).await.unwrap(); + storage.insert_media(&item_c).await.unwrap(); + + let ids = [item_a.id, item_b.id, item_c.id]; + let count = storage + .batch_update_media( + &ids, + Some("Shared Title"), + Some("Shared Artist"), + Some("Shared Album"), + Some("Jazz"), + Some(2025), + Some("Batch desc"), + ) + .await + .unwrap(); + assert_eq!(count, 3); + + for id in &ids { + let fetched = storage.get_media(*id).await.unwrap(); + assert_eq!(fetched.title.as_deref(), Some("Shared Title")); + assert_eq!(fetched.artist.as_deref(), Some("Shared Artist")); + assert_eq!(fetched.album.as_deref(), Some("Shared Album")); + assert_eq!(fetched.genre.as_deref(), Some("Jazz")); + assert_eq!(fetched.year, Some(2025)); + assert_eq!(fetched.description.as_deref(), Some("Batch desc")); + } +} + +#[tokio::test] +async fn test_batch_update_media_subset_of_items() { + let storage = setup().await; + + let item_a = make_test_media("bum_subset_a"); + let item_b = make_test_media("bum_subset_b"); + storage.insert_media(&item_a).await.unwrap(); + storage.insert_media(&item_b).await.unwrap(); + + // Only update item_a. + let count = storage + .batch_update_media( + &[item_a.id], + Some("Only A"), + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(count, 1); + + let fetched_a = storage.get_media(item_a.id).await.unwrap(); + let fetched_b = storage.get_media(item_b.id).await.unwrap(); + assert_eq!(fetched_a.title.as_deref(), Some("Only A")); + // item_b must be untouched. + assert_eq!(fetched_b.title, item_b.title); +} diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index 340dc24..a7229c0 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -759,11 +759,19 @@ wasm = "plugin.wasm" let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!( - manifest.ui.theme_extensions.get("--accent-color").map(String::as_str), + manifest + .ui + .theme_extensions + .get("--accent-color") + .map(String::as_str), Some("#ff6b6b") ); assert_eq!( - manifest.ui.theme_extensions.get("--sidebar-width").map(String::as_str), + manifest + .ui + .theme_extensions + .get("--sidebar-width") + .map(String::as_str), Some("280px") ); } diff --git a/crates/pinakes-plugin-api/src/ui_schema.rs b/crates/pinakes-plugin-api/src/ui_schema.rs index 783237e..c96f1d3 100644 --- a/crates/pinakes-plugin-api/src/ui_schema.rs +++ b/crates/pinakes-plugin-api/src/ui_schema.rs @@ -25,7 +25,7 @@ //! "sidebar": { //! "type": "list", //! "data": "playlists", -//! "item_template": { "type": "text", "content": "{{title}}" } +//! "item_template": { "type": "text", "content": "title" } //! }, //! "main": { //! "type": "data_table", @@ -40,6 +40,11 @@ //! "playlists": { "type": "endpoint", "path": "/api/v1/collections" } //! } //! } +//! +//! Note: expression values are `Expression::Path` strings, not mustache +//! templates. A bare string like `"title"` resolves the `title` field in the +//! current item context. Nested fields use dotted segments: `"artist.name"`. +//! Array indices use the same notation: `"items.0.title"`. //! ``` use std::collections::HashMap; @@ -102,6 +107,7 @@ pub type SchemaResult = Result; /// padding: None, /// }, /// data_sources: Default::default(), +/// actions: Default::default(), /// }; /// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -127,6 +133,10 @@ pub struct UiPage { /// Named data sources available to this page #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub data_sources: HashMap, + + /// Named actions available to this page (referenced by `ActionRef::Name`) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub actions: HashMap, } impl UiPage { @@ -151,6 +161,13 @@ impl UiPage { )); } + if crate::validation::SchemaValidator::is_reserved_route(&self.route) { + return Err(SchemaError::ValidationError(format!( + "Route '{}' conflicts with a built-in app route", + self.route + ))); + } + let depth = self.root_element.depth(); if depth > MAX_ELEMENT_DEPTH { return Err(SchemaError::DepthLimitExceeded); @@ -158,6 +175,11 @@ impl UiPage { self.root_element.validate(self)?; + for (name, action) in &self.actions { + validate_id(name)?; + action.validate()?; + } + for (name, source) in &self.data_sources { validate_id(name)?; source.validate()?; @@ -246,6 +268,23 @@ pub struct UiWidget { pub content: UiElement, } +impl UiWidget { + /// Validates this widget definition + /// + /// # Errors + /// + /// Returns `SchemaError::ValidationError` if validation fails + pub fn validate(&self) -> SchemaResult<()> { + if self.target.is_empty() { + return Err(SchemaError::ValidationError( + "Widget target cannot be empty".to_string(), + )); + } + validate_id(&self.id)?; + Ok(()) + } +} + /// String constants for widget injection locations. /// /// Use these with `UiWidget::target` in plugin manifests: @@ -259,6 +298,7 @@ pub mod widget_location { pub const LIBRARY_SIDEBAR: &str = "library_sidebar"; pub const DETAIL_PANEL: &str = "detail_panel"; pub const SEARCH_FILTERS: &str = "search_filters"; + pub const SETTINGS_SECTION: &str = "settings_section"; } /// Core UI element enum - the building block of all plugin UIs @@ -761,11 +801,6 @@ impl UiElement { ))); } }, - Self::Form { fields, .. } if fields.is_empty() => { - return Err(SchemaError::ValidationError( - "Form must have at least one field".to_string(), - )); - }, Self::Chart { data, .. } if !page.data_sources.contains_key(data) => { return Err(SchemaError::ValidationError(format!( "Chart references unknown data source: {data}" @@ -817,11 +852,21 @@ impl UiElement { Self::Button { action, .. } => { action.validate()?; }, + Self::Link { href, .. } if !is_safe_href(href) => { + return Err(SchemaError::ValidationError(format!( + "Link href has a disallowed scheme (must be '/', 'http://', or 'https://'): {href}" + ))); + }, Self::Form { fields, submit_action, .. } => { + if fields.is_empty() { + return Err(SchemaError::ValidationError( + "Form must have at least one field".to_string(), + )); + } for field in fields { validate_id(&field.id)?; if field.label.is_empty() { @@ -1046,7 +1091,7 @@ pub struct ColumnDef { } /// Row action for `DataTable` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct RowAction { /// Action identifier (unique within this table) pub id: String, @@ -1290,15 +1335,60 @@ pub enum ChartType { Scatter, } +/// Client-side action types that do not require an HTTP call. +/// +/// Used as `{"action": "", ...}` in JSON. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum SpecialAction { + /// Trigger a data refresh (re-runs all data sources for the current page). + Refresh, + /// Navigate to a different route. + Navigate { + /// Target route path (must start with `/`) + to: String, + }, + /// Emit a named event to the server-side plugin event bus. + Emit { + /// Event name + event: String, + /// Optional payload (any JSON value) + #[serde(default)] + payload: serde_json::Value, + }, + /// Update a local state key (resolved against the current data context). + UpdateState { + /// State key name + key: String, + /// Expression whose value is stored at `key` + value: Expression, + }, + /// Open a modal overlay containing the given element. + OpenModal { + /// Element to render inside the modal + content: Box, + }, + /// Close the currently open modal overlay. + CloseModal, +} + /// Action reference - identifies an action to execute -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// +/// Deserialization order for `#[serde(untagged)]`: +/// 1. `Special` - JSON objects with an `"action"` string key +/// 2. `Inline` - JSON objects with a `"path"` key +/// 3. `Name` - bare JSON strings +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(untagged)] pub enum ActionRef { + /// Client-side special action (no HTTP call required) + Special(SpecialAction), + + /// Inline action definition (HTTP call) + Inline(ActionDefinition), + /// Simple action name (references page.actions) Name(String), - - /// Inline action definition - Inline(ActionDefinition), } impl ActionRef { @@ -1312,6 +1402,26 @@ impl ActionRef { /// Returns `SchemaError::ValidationError` if validation fails. pub fn validate(&self) -> SchemaResult<()> { match self { + Self::Special(s) => { + match s { + SpecialAction::Navigate { to } if to.is_empty() => { + return Err(SchemaError::ValidationError( + "Navigate.to cannot be empty".to_string(), + )); + }, + SpecialAction::UpdateState { key, .. } if key.is_empty() => { + return Err(SchemaError::ValidationError( + "UpdateState.key cannot be empty".to_string(), + )); + }, + SpecialAction::Emit { event, .. } if event.is_empty() => { + return Err(SchemaError::ValidationError( + "Emit.event cannot be empty".to_string(), + )); + }, + _ => {}, + } + }, Self::Name(name) => { if name.is_empty() { return Err(SchemaError::ValidationError( @@ -1376,6 +1486,18 @@ impl ActionDefinition { self.path ))); } + if !self.path.starts_with("/api/") { + return Err(SchemaError::ValidationError(format!( + "Action path must start with '/api/': {}", + self.path + ))); + } + if self.path.contains("..") { + return Err(SchemaError::ValidationError(format!( + "Action path contains invalid traversal sequence: {}", + self.path + ))); + } Ok(()) } } @@ -1462,6 +1584,16 @@ impl DataSource { "Endpoint path must start with '/': {path}" ))); } + if !path.starts_with("/api/") { + return Err(SchemaError::InvalidDataSource(format!( + "Endpoint path must start with '/api/': {path}" + ))); + } + if path.contains("..") { + return Err(SchemaError::InvalidDataSource(format!( + "Endpoint path contains invalid traversal sequence: {path}" + ))); + } }, Self::Transform { source_name, .. } => { validate_id(source_name)?; @@ -1475,16 +1607,31 @@ impl DataSource { /// Expression for dynamic value evaluation /// /// Expressions use JSONPath-like syntax for data access. +/// +/// ## JSON representation (serde untagged; order matters) +/// +/// Variants are tried in declaration order during deserialization: +/// +/// | JSON shape | Deserializes as | +/// |---------------------------------------------------|-----------------| +/// | `"users.0.name"` (string) | `Path` | +/// | `{"left":…,"op":"eq","right":…}` (object) | `Operation` | +/// | `{"function":"len","args":[…]}` (object) | `Call` | +/// | `42`, `true`, `null`, `[…]`, `{other fields}` … | `Literal` | +/// +/// `Literal` is intentionally last so that the more specific variants take +/// priority. A bare JSON string is always a **path reference**; to embed a +/// literal string value use `DataSource::Static` or a `Call` expression. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(untagged)] pub enum Expression { - /// Literal JSON value - Literal(serde_json::Value), - - /// Data path reference (e.g., "$.users[0].name") + /// Data path reference: a dotted key sequence walked against the context. + /// + /// e.g. `"user.name"` resolves to `ctx["user"]["name"]`; `"items.0"` resolves + /// to the first element. Path(String), - /// Binary operation + /// Binary operation applied to two sub-expressions. Operation { /// Left operand left: Box, @@ -1494,13 +1641,22 @@ pub enum Expression { right: Box, }, - /// Function call + /// Built-in function call. + /// + /// e.g. `{"function": "len", "args": ["tags"]}` returns the count of items + /// in the `tags` data source. Call { - /// Function name + /// Function name (see built-in function table in docs) function: String, - /// Function arguments + /// Positional arguments, each an `Expression` args: Vec, }, + + /// Literal JSON value: a constant that is returned unchanged. + /// + /// Matches numbers, booleans, null, arrays, and objects that do not match + /// the `Operation` or `Call` shapes above. + Literal(serde_json::Value), } impl Default for Expression { @@ -1579,6 +1735,18 @@ const fn default_http_method() -> HttpMethod { HttpMethod::Get } +/// Returns `true` if `href` uses a scheme safe to render in an anchor element. +/// +/// Allows relative paths (`/`), plain `http://`, and `https://`. Rejects +/// `javascript:`, `data:`, `vbscript:`, and any other scheme that could be +/// used for script injection or data exfiltration. +#[must_use] +pub fn is_safe_href(href: &str) -> bool { + href.starts_with('/') + || href.starts_with("https://") + || href.starts_with("http://") +} + /// Validates an identifier string /// /// IDs must: @@ -1729,6 +1897,7 @@ mod tests { row_actions: vec![], }, data_sources: HashMap::new(), + actions: HashMap::new(), }; let refs = page.referenced_data_sources(); @@ -1748,6 +1917,7 @@ mod tests { gap: 16, }, data_sources: HashMap::new(), + actions: HashMap::new(), }; assert!(page.validate().is_err()); @@ -1766,8 +1936,288 @@ mod tests { id: None, }, data_sources: HashMap::new(), + actions: HashMap::new(), }; assert!(page.validate().is_err()); } + + // Expression JSON round-trip tests + + /// A JSON string must deserialise as Path, not Literal. + #[test] + fn test_expression_string_deserialises_as_path() { + let expr: Expression = serde_json::from_str(r#""user.name""#).unwrap(); + assert_eq!(expr, Expression::Path("user.name".to_string())); + } + + /// A JSON number must deserialise as Literal, not Path. + #[test] + fn test_expression_number_deserialises_as_literal() { + let expr: Expression = serde_json::from_str("42").unwrap(); + assert_eq!(expr, Expression::Literal(serde_json::json!(42))); + } + + /// An Operation object is correctly deserialised. + #[test] + fn test_expression_operation_deserialises() { + let json = r#"{"left": "count", "op": "gt", "right": 0}"#; + let expr: Expression = serde_json::from_str(json).unwrap(); + match expr { + Expression::Operation { left, op, right } => { + assert_eq!(*left, Expression::Path("count".to_string())); + assert_eq!(op, Operator::Gt); + assert_eq!(*right, Expression::Literal(serde_json::json!(0))); + }, + other => panic!("expected Operation, got {other:?}"), + } + } + + /// A Call object is correctly deserialised. + #[test] + fn test_expression_call_deserialises() { + let json = r#"{"function": "len", "args": ["items"]}"#; + let expr: Expression = serde_json::from_str(json).unwrap(); + match expr { + Expression::Call { function, args } => { + assert_eq!(function, "len"); + assert_eq!(args, vec![Expression::Path("items".to_string())]); + }, + other => panic!("expected Call, got {other:?}"), + } + } + + /// Path expressions survive a full JSON round-trip. + #[test] + fn test_expression_path_round_trip() { + let original = Expression::Path("a.b.c".to_string()); + let json = serde_json::to_string(&original).unwrap(); + let recovered: Expression = serde_json::from_str(&json).unwrap(); + assert_eq!(original, recovered); + } + + // DataSource/ActionDefinition security validation tests + + #[test] + fn test_endpoint_path_must_start_with_api() { + let bad = DataSource::Endpoint { + method: HttpMethod::Get, + path: "/not-api/something".to_string(), + params: HashMap::new(), + poll_interval: 0, + transform: None, + }; + assert!(bad.validate().is_err()); + } + + #[test] + fn test_endpoint_path_rejects_traversal() { + let bad = DataSource::Endpoint { + method: HttpMethod::Get, + path: "/api/v1/../admin".to_string(), + params: HashMap::new(), + poll_interval: 0, + transform: None, + }; + assert!(bad.validate().is_err()); + } + + #[test] + fn test_action_path_must_start_with_api() { + let bad = ActionDefinition { + method: HttpMethod::Post, + path: "/admin/reset".to_string(), + ..ActionDefinition::default() + }; + assert!(bad.validate().is_err()); + } + + #[test] + fn test_action_path_rejects_traversal() { + let bad = ActionDefinition { + method: HttpMethod::Post, + path: "/api/v1/tags/../../auth/login".to_string(), + ..ActionDefinition::default() + }; + assert!(bad.validate().is_err()); + } + + // Link href safety tests + + #[test] + fn test_is_safe_href_allows_relative() { + assert!(is_safe_href("/some/path")); + } + + #[test] + fn test_is_safe_href_allows_https() { + assert!(is_safe_href("https://example.com/page")); + } + + #[test] + fn test_is_safe_href_allows_http() { + assert!(is_safe_href("http://example.com/page")); + } + + #[test] + fn test_is_safe_href_rejects_javascript() { + assert!(!is_safe_href("javascript:alert(1)")); + } + + #[test] + fn test_is_safe_href_rejects_data_uri() { + assert!(!is_safe_href("data:text/html,")); + } + + #[test] + fn test_is_safe_href_rejects_vbscript() { + assert!(!is_safe_href("vbscript:msgbox(1)")); + } + + #[test] + fn test_link_validation_rejects_unsafe_href() { + use std::collections::HashMap as HM; + let page = UiPage { + id: "p".to_string(), + title: "P".to_string(), + route: "/api/plugins/p/p".to_string(), + icon: None, + root_element: UiElement::Link { + text: "click".to_string(), + href: "javascript:alert(1)".to_string(), + external: false, + }, + data_sources: HM::new(), + actions: HM::new(), + }; + assert!(page.validate().is_err()); + } + + #[test] + fn test_reserved_route_rejected() { + use std::collections::HashMap as HM; + let page = UiPage { + id: "search-page".to_string(), + title: "Search".to_string(), + route: "/search".to_string(), + icon: None, + root_element: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + data_sources: HM::new(), + actions: HM::new(), + }; + let err = page.validate().unwrap_err(); + assert!( + matches!(err, SchemaError::ValidationError(_)), + "expected ValidationError, got {err:?}" + ); + assert!( + format!("{err}").contains("/search"), + "error should mention the conflicting route" + ); + } + + // --- SpecialAction JSON round-trips --- + + #[test] + fn test_special_action_refresh_roundtrip() { + let action = SpecialAction::Refresh; + let json = serde_json::to_value(&action).unwrap(); + assert_eq!(json["action"], "refresh"); + let back: SpecialAction = serde_json::from_value(json).unwrap(); + assert_eq!(back, SpecialAction::Refresh); + } + + #[test] + fn test_special_action_navigate_roundtrip() { + let action = SpecialAction::Navigate { + to: "/foo".to_string(), + }; + let json = serde_json::to_value(&action).unwrap(); + assert_eq!(json["action"], "navigate"); + assert_eq!(json["to"], "/foo"); + let back: SpecialAction = serde_json::from_value(json).unwrap(); + assert_eq!(back, SpecialAction::Navigate { + to: "/foo".to_string(), + }); + } + + #[test] + fn test_special_action_emit_roundtrip() { + let action = SpecialAction::Emit { + event: "my-event".to_string(), + payload: serde_json::json!({"key": "val"}), + }; + let json = serde_json::to_value(&action).unwrap(); + assert_eq!(json["action"], "emit"); + assert_eq!(json["event"], "my-event"); + let back: SpecialAction = serde_json::from_value(json).unwrap(); + assert_eq!(back, action); + } + + #[test] + fn test_special_action_update_state_roundtrip() { + let action = SpecialAction::UpdateState { + key: "my-key".to_string(), + value: Expression::Literal(serde_json::json!(42)), + }; + let json = serde_json::to_value(&action).unwrap(); + assert_eq!(json["action"], "update_state"); + assert_eq!(json["key"], "my-key"); + let back: SpecialAction = serde_json::from_value(json).unwrap(); + assert_eq!(back, action); + } + + #[test] + fn test_special_action_close_modal_roundtrip() { + let action = SpecialAction::CloseModal; + let json = serde_json::to_value(&action).unwrap(); + assert_eq!(json["action"], "close_modal"); + let back: SpecialAction = serde_json::from_value(json).unwrap(); + assert_eq!(back, SpecialAction::CloseModal); + } + + // --- ActionRef deserialization ordering --- + + #[test] + fn test_action_ref_special_refresh_deserializes() { + let json = serde_json::json!({"action": "refresh"}); + let action_ref: ActionRef = serde_json::from_value(json).unwrap(); + assert!(matches!( + action_ref, + ActionRef::Special(SpecialAction::Refresh) + )); + } + + #[test] + fn test_action_ref_special_navigate_deserializes() { + let json = serde_json::json!({"action": "navigate", "to": "/foo"}); + let action_ref: ActionRef = serde_json::from_value(json).unwrap(); + assert!(matches!( + action_ref, + ActionRef::Special(SpecialAction::Navigate { to }) if to == "/foo" + )); + } + + #[test] + fn test_action_ref_name_still_works() { + let json = serde_json::json!("my-action"); + let action_ref: ActionRef = serde_json::from_value(json).unwrap(); + assert!(matches!(action_ref, ActionRef::Name(n) if n == "my-action")); + } + + #[test] + fn test_action_ref_special_takes_priority_over_inline() { + // An object with "action":"refresh" must be SpecialAction, not + // misinterpreted as ActionDefinition. + let json = serde_json::json!({"action": "refresh"}); + let action_ref: ActionRef = serde_json::from_value(json).unwrap(); + assert!( + matches!(action_ref, ActionRef::Special(_)), + "SpecialAction must be matched before ActionDefinition" + ); + } } diff --git a/crates/pinakes-plugin-api/src/validation.rs b/crates/pinakes-plugin-api/src/validation.rs index d232f29..b7bb445 100644 --- a/crates/pinakes-plugin-api/src/validation.rs +++ b/crates/pinakes-plugin-api/src/validation.rs @@ -122,6 +122,10 @@ impl SchemaValidator { Self::validate_element(&widget.content, &mut errors); + if Self::element_references_data_source(&widget.content) { + errors.push("widgets cannot reference data sources".to_string()); + } + if errors.is_empty() { Ok(()) } else { @@ -132,19 +136,9 @@ impl SchemaValidator { /// Recursively validate a [`UiElement`] subtree. pub fn validate_element(element: &UiElement, errors: &mut Vec) { match element { - UiElement::Container { children, .. } => { - for child in children { - Self::validate_element(child, errors); - } - }, - - UiElement::Grid { children, .. } => { - for child in children { - Self::validate_element(child, errors); - } - }, - - UiElement::Flex { children, .. } => { + UiElement::Container { children, .. } + | UiElement::Grid { children, .. } + | UiElement::Flex { children, .. } => { for child in children { Self::validate_element(child, errors); } @@ -206,10 +200,15 @@ impl SchemaValidator { } }, - UiElement::List { data, .. } => { + UiElement::List { + data, + item_template, + .. + } => { if data.is_empty() { errors.push("List 'data' source key must not be empty".to_string()); } + Self::validate_element(item_template, errors); }, // Leaf elements with no children to recurse into @@ -226,6 +225,66 @@ impl SchemaValidator { } } + /// Returns true if any element in the tree references a named data source. + /// + /// Widgets have no data-fetching mechanism, so any data source reference + /// in a widget content tree is invalid and must be rejected at load time. + fn element_references_data_source(element: &UiElement) -> bool { + match element { + // Variants that reference a data source by name + UiElement::DataTable { .. } + | UiElement::MediaGrid { .. } + | UiElement::DescriptionList { .. } + | UiElement::Chart { .. } + | UiElement::Loop { .. } + | UiElement::List { .. } => true, + + // Container variants - recurse into children + UiElement::Container { children, .. } + | UiElement::Grid { children, .. } + | UiElement::Flex { children, .. } => { + children.iter().any(Self::element_references_data_source) + }, + + UiElement::Split { sidebar, main, .. } => { + Self::element_references_data_source(sidebar) + || Self::element_references_data_source(main) + }, + + UiElement::Tabs { tabs, .. } => { + tabs + .iter() + .any(|tab| Self::element_references_data_source(&tab.content)) + }, + + UiElement::Card { + content, footer, .. + } => { + content.iter().any(Self::element_references_data_source) + || footer.iter().any(Self::element_references_data_source) + }, + + UiElement::Conditional { + then, else_element, .. + } => { + Self::element_references_data_source(then) + || else_element + .as_ref() + .is_some_and(|e| Self::element_references_data_source(e)) + }, + + // Leaf elements with no data source references + UiElement::Heading { .. } + | UiElement::Text { .. } + | UiElement::Code { .. } + | UiElement::Button { .. } + | UiElement::Form { .. } + | UiElement::Link { .. } + | UiElement::Progress { .. } + | UiElement::Badge { .. } => false, + } + } + fn validate_data_source( name: &str, source: &DataSource, @@ -243,6 +302,12 @@ impl SchemaValidator { "Data source '{name}': endpoint path must start with '/': {path}" )); } + if !path.starts_with("/api/") { + errors.push(format!( + "DataSource '{name}': endpoint path must start with /api/ (got \ + '{path}')" + )); + } }, DataSource::Transform { source_name, .. } => { if source_name.is_empty() { @@ -264,9 +329,11 @@ impl SchemaValidator { && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } - fn is_reserved_route(route: &str) -> bool { + pub(crate) fn is_reserved_route(route: &str) -> bool { RESERVED_ROUTES.iter().any(|reserved| { - route == *reserved || route.starts_with(&format!("{reserved}/")) + route == *reserved + || (route.starts_with(reserved) + && route.as_bytes().get(reserved.len()) == Some(&b'/')) }) } } @@ -290,6 +357,7 @@ mod tests { padding: None, }, data_sources: HashMap::new(), + actions: HashMap::new(), } } @@ -580,4 +648,81 @@ mod tests { }; assert!(SchemaValidator::validate_page(&page).is_err()); } + + #[test] + fn test_widget_badge_content_passes_validation() { + let widget = crate::UiWidget { + id: "status-badge".to_string(), + target: "library_header".to_string(), + content: UiElement::Badge { + text: "active".to_string(), + variant: Default::default(), + }, + }; + assert!( + SchemaValidator::validate_widget(&widget).is_ok(), + "a widget with Badge content should pass validation" + ); + } + + #[test] + fn test_widget_datatable_fails_validation() { + let col: crate::ColumnDef = + serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"})) + .unwrap(); + let widget = crate::UiWidget { + id: "my-widget".to_string(), + target: "library_header".to_string(), + content: UiElement::DataTable { + data: "items".to_string(), + columns: vec![col], + sortable: false, + filterable: false, + page_size: 0, + row_actions: vec![], + }, + }; + let result = SchemaValidator::validate_widget(&widget); + assert!( + result.is_err(), + "DataTable in widget should fail validation" + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("cannot reference data sources"), + "error message should mention data sources: {err}" + ); + } + + #[test] + fn test_widget_container_with_loop_fails_validation() { + // Container whose child is a Loop - recursive check must catch it + let widget = crate::UiWidget { + id: "loop-widget".to_string(), + target: "library_header".to_string(), + content: UiElement::Container { + children: vec![UiElement::Loop { + data: "items".to_string(), + template: Box::new(UiElement::Text { + content: Default::default(), + variant: Default::default(), + allow_html: false, + }), + empty: None, + }], + gap: 0, + padding: None, + }, + }; + let result = SchemaValidator::validate_widget(&widget); + assert!( + result.is_err(), + "Container wrapping a Loop should fail widget validation" + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("cannot reference data sources"), + "error message should mention data sources: {err}" + ); + } } diff --git a/crates/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs index 800f951..e404776 100644 --- a/crates/pinakes-server/src/dto/media.rs +++ b/crates/pinakes-server/src/dto/media.rs @@ -1,9 +1,40 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// Strip the longest matching root prefix from `full_path`, returning a +/// forward-slash-separated relative path string. Falls back to the full path +/// string when no root matches. If `roots` is empty, returns the full path as a +/// string so internal callers that have not yet migrated still work. +#[must_use] +pub fn relativize_path(full_path: &Path, roots: &[PathBuf]) -> String { + let mut best: Option<&PathBuf> = None; + for root in roots { + if full_path.starts_with(root) { + let is_longer = best + .is_none_or(|b| root.components().count() > b.components().count()); + if is_longer { + best = Some(root); + } + } + } + if let Some(root) = best + && let Ok(rel) = full_path.strip_prefix(root) { + // Normalise to forward slashes on all platforms. + return rel + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + } + full_path.to_string_lossy().into_owned() +} + #[derive(Debug, Serialize)] pub struct MediaResponse { pub id: String, @@ -233,12 +264,16 @@ impl From } } -// Conversion helpers -impl From for MediaResponse { - fn from(item: pinakes_core::model::MediaItem) -> Self { +impl MediaResponse { + /// Build a `MediaResponse` from a `MediaItem`, stripping the longest + /// matching root prefix from the path before serialization. Pass the + /// configured root directories so that clients receive a relative path + /// (e.g. `"Music/song.mp3"`) rather than a full server filesystem path. + #[must_use] + pub fn new(item: pinakes_core::model::MediaItem, roots: &[PathBuf]) -> Self { Self { id: item.id.0.to_string(), - path: item.path.to_string_lossy().to_string(), + path: relativize_path(&item.path, roots), file_name: item.file_name, media_type: serde_json::to_value(item.media_type) .ok() @@ -282,6 +317,57 @@ impl From for MediaResponse { } } +// Conversion helpers +impl From for MediaResponse { + /// Convert using no root stripping. Prefer `MediaResponse::new(item, roots)` + /// at route-handler call sites where roots are available. + fn from(item: pinakes_core::model::MediaItem) -> Self { + Self::new(item, &[]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relativize_path_strips_matching_root() { + let roots = vec![PathBuf::from("/home/user/music")]; + let path = Path::new("/home/user/music/artist/song.mp3"); + assert_eq!(relativize_path(path, &roots), "artist/song.mp3"); + } + + #[test] + fn relativize_path_picks_longest_root() { + let roots = vec![ + PathBuf::from("/home/user"), + PathBuf::from("/home/user/music"), + ]; + let path = Path::new("/home/user/music/song.mp3"); + assert_eq!(relativize_path(path, &roots), "song.mp3"); + } + + #[test] + fn relativize_path_no_match_returns_full() { + let roots = vec![PathBuf::from("/home/user/music")]; + let path = Path::new("/srv/videos/movie.mkv"); + assert_eq!(relativize_path(path, &roots), "/srv/videos/movie.mkv"); + } + + #[test] + fn relativize_path_empty_roots_returns_full() { + let path = Path::new("/home/user/music/song.mp3"); + assert_eq!(relativize_path(path, &[]), "/home/user/music/song.mp3"); + } + + #[test] + fn relativize_path_exact_root_match() { + let roots = vec![PathBuf::from("/media/library")]; + let path = Path::new("/media/library/file.mp3"); + assert_eq!(relativize_path(path, &roots), "file.mp3"); + } +} + // Watch progress #[derive(Debug, Deserialize)] pub struct WatchProgressRequest { diff --git a/crates/pinakes-server/src/routes/analytics.rs b/crates/pinakes-server/src/routes/analytics.rs index 19a3ef0..1698061 100644 --- a/crates/pinakes-server/src/routes/analytics.rs +++ b/crates/pinakes-server/src/routes/analytics.rs @@ -30,12 +30,13 @@ pub async fn get_most_viewed( ) -> Result>, ApiError> { let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); let results = state.storage.get_most_viewed(limit).await?; + let roots = state.config.read().await.directories.roots.clone(); Ok(Json( results .into_iter() .map(|(item, count)| { MostViewedResponse { - media: MediaResponse::from(item), + media: MediaResponse::new(item, &roots), view_count: count, } }) @@ -51,7 +52,13 @@ pub async fn get_recently_viewed( let user_id = resolve_user_id(&state.storage, &username).await?; let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); let items = state.storage.get_recently_viewed(user_id, limit).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } pub async fn record_event( diff --git a/crates/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs index f513d9c..9e3a0bc 100644 --- a/crates/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -194,8 +194,11 @@ pub async fn list_books( ) .await?; - let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let roots = state.config.read().await.directories.roots.clone(); + let response: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(response)) } @@ -223,8 +226,11 @@ pub async fn get_series_books( Path(series_name): Path, ) -> Result { let items = state.storage.get_series_books(&series_name).await?; - let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let roots = state.config.read().await.directories.roots.clone(); + let response: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(response)) } @@ -258,8 +264,11 @@ pub async fn get_author_books( .search_books(None, Some(&author_name), None, None, None, &pagination) .await?; - let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let roots = state.config.read().await.directories.roots.clone(); + let response: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(response)) } @@ -317,8 +326,11 @@ pub async fn get_reading_list( .get_reading_list(user_id.0, params.status) .await?; - let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let roots = state.config.read().await.directories.roots.clone(); + let response: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(response)) } diff --git a/crates/pinakes-server/src/routes/collections.rs b/crates/pinakes-server/src/routes/collections.rs index 159d125..c746fa8 100644 --- a/crates/pinakes-server/src/routes/collections.rs +++ b/crates/pinakes-server/src/routes/collections.rs @@ -126,5 +126,11 @@ pub async fn get_members( let items = pinakes_core::collections::get_members(&state.storage, collection_id) .await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } diff --git a/crates/pinakes-server/src/routes/duplicates.rs b/crates/pinakes-server/src/routes/duplicates.rs index 4da2ac8..075b3cc 100644 --- a/crates/pinakes-server/src/routes/duplicates.rs +++ b/crates/pinakes-server/src/routes/duplicates.rs @@ -10,6 +10,7 @@ pub async fn list_duplicates( State(state): State, ) -> Result>, ApiError> { let groups = state.storage.find_duplicates().await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = groups .into_iter() @@ -18,8 +19,10 @@ pub async fn list_duplicates( .first() .map(|i| i.content_hash.0.clone()) .unwrap_or_default(); - let media_items: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let media_items: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); DuplicateGroupResponse { content_hash, items: media_items, diff --git a/crates/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs index a2b3a4a..358db29 100644 --- a/crates/pinakes-server/src/routes/media.rs +++ b/crates/pinakes-server/src/routes/media.rs @@ -120,7 +120,13 @@ pub async fn list_media( params.sort, ); let items = state.storage.list_media(&pagination).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } pub async fn get_media( @@ -128,7 +134,8 @@ pub async fn get_media( Path(id): Path, ) -> Result, ApiError> { let item = state.storage.get_media(MediaId(id)).await?; - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } /// Maximum length for short text fields (title, artist, album, genre). @@ -206,7 +213,8 @@ pub async fn update_media( &serde_json::json!({"media_id": item.id.to_string()}), ); - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } pub async fn delete_media( @@ -574,12 +582,14 @@ pub async fn preview_directory( } } + let roots_for_walk = roots.clone(); let files: Vec = tokio::task::spawn_blocking(move || { let mut result = Vec::new(); fn walk_dir( dir: &std::path::Path, recursive: bool, + roots: &[std::path::PathBuf], result: &mut Vec, ) { let Ok(entries) = std::fs::read_dir(dir) else { @@ -596,7 +606,7 @@ pub async fn preview_directory( } if path.is_dir() { if recursive { - walk_dir(&path, recursive, result); + walk_dir(&path, recursive, roots, result); } } else if path.is_file() && let Some(mt) = @@ -612,7 +622,7 @@ pub async fn preview_directory( .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(); result.push(DirectoryPreviewFile { - path: path.to_string_lossy().to_string(), + path: crate::dto::relativize_path(&path, roots), file_name, media_type, file_size: size, @@ -620,7 +630,7 @@ pub async fn preview_directory( } } } - walk_dir(&dir, recursive, &mut result); + walk_dir(&dir, recursive, &roots_for_walk, &mut result); result }) .await @@ -948,7 +958,8 @@ pub async fn rename_media( ) .await?; - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } pub async fn move_media_endpoint( @@ -994,7 +1005,8 @@ pub async fn move_media_endpoint( ) .await?; - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } pub async fn batch_move_media( @@ -1144,7 +1156,8 @@ pub async fn restore_media( &serde_json::json!({"media_id": media_id.to_string(), "restored": true}), ); - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } pub async fn list_trash( @@ -1159,9 +1172,13 @@ pub async fn list_trash( let items = state.storage.list_trash(&pagination).await?; let count = state.storage.count_trash().await?; + let roots = state.config.read().await.directories.roots.clone(); Ok(Json(TrashResponse { - items: items.into_iter().map(MediaResponse::from).collect(), + items: items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), total_count: count, })) } diff --git a/crates/pinakes-server/src/routes/photos.rs b/crates/pinakes-server/src/routes/photos.rs index edf04b6..4119774 100644 --- a/crates/pinakes-server/src/routes/photos.rs +++ b/crates/pinakes-server/src/routes/photos.rs @@ -121,13 +121,16 @@ pub async fn get_timeline( } // Convert to response format + let roots = state.config.read().await.directories.roots.clone(); let mut timeline: Vec = groups .into_iter() .map(|(date, items)| { let cover_id = items.first().map(|i| i.id.0.to_string()); let count = items.len(); - let items: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let items: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); TimelineGroup { date, diff --git a/crates/pinakes-server/src/routes/playlists.rs b/crates/pinakes-server/src/routes/playlists.rs index 15df830..d4c4d9f 100644 --- a/crates/pinakes-server/src/routes/playlists.rs +++ b/crates/pinakes-server/src/routes/playlists.rs @@ -185,7 +185,13 @@ pub async fn list_items( let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, false).await?; let items = state.storage.get_playlist_items(id).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } pub async fn reorder_item( @@ -213,5 +219,11 @@ pub async fn shuffle_playlist( use rand::seq::SliceRandom; let mut items = state.storage.get_playlist_items(id).await?; items.shuffle(&mut rand::rng()); - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } diff --git a/crates/pinakes-server/src/routes/plugins.rs b/crates/pinakes-server/src/routes/plugins.rs index 5653d23..6748282 100644 --- a/crates/pinakes-server/src/routes/plugins.rs +++ b/crates/pinakes-server/src/routes/plugins.rs @@ -153,10 +153,12 @@ pub async fn list_plugin_ui_pages( let pages = plugin_manager.list_ui_pages_with_endpoints().await; let entries = pages .into_iter() - .map(|(plugin_id, page, allowed_endpoints)| PluginUiPageEntry { - plugin_id, - page, - allowed_endpoints, + .map(|(plugin_id, page, allowed_endpoints)| { + PluginUiPageEntry { + plugin_id, + page, + allowed_endpoints, + } }) .collect(); Ok(Json(entries)) diff --git a/crates/pinakes-server/src/routes/search.rs b/crates/pinakes-server/src/routes/search.rs index 3201047..7f0e6b1 100644 --- a/crates/pinakes-server/src/routes/search.rs +++ b/crates/pinakes-server/src/routes/search.rs @@ -51,9 +51,14 @@ pub async fn search( }; let results = state.storage.search(&request).await?; + let roots = state.config.read().await.directories.roots.clone(); Ok(Json(SearchResponse { - items: results.items.into_iter().map(MediaResponse::from).collect(), + items: results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), total_count: results.total_count, })) } @@ -84,9 +89,14 @@ pub async fn search_post( }; let results = state.storage.search(&request).await?; + let roots = state.config.read().await.directories.roots.clone(); Ok(Json(SearchResponse { - items: results.items.into_iter().map(MediaResponse::from).collect(), + items: results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), total_count: results.total_count, })) } diff --git a/crates/pinakes-server/src/routes/shares.rs b/crates/pinakes-server/src/routes/shares.rs index 06b7e6d..76fea3c 100644 --- a/crates/pinakes-server/src/routes/shares.rs +++ b/crates/pinakes-server/src/routes/shares.rs @@ -506,6 +506,7 @@ pub async fn access_shared( let _ = state.storage.record_share_activity(&activity).await; // Return the shared content + let roots = state.config.read().await.directories.roots.clone(); match &share.target { ShareTarget::Media { media_id } => { let item = state @@ -514,8 +515,8 @@ pub async fn access_shared( .await .map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?; - Ok(Json(SharedContentResponse::Single(MediaResponse::from( - item, + Ok(Json(SharedContentResponse::Single(MediaResponse::new( + item, &roots, )))) }, ShareTarget::Collection { collection_id } => { @@ -527,8 +528,10 @@ pub async fn access_shared( ApiError::not_found(format!("Collection not found: {e}")) })?; - let items: Vec = - members.into_iter().map(MediaResponse::from).collect(); + let items: Vec = members + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, @@ -553,8 +556,11 @@ pub async fn access_shared( .await .map_err(|e| ApiError::internal(format!("Search failed: {e}")))?; - let items: Vec = - results.items.into_iter().map(MediaResponse::from).collect(); + let items: Vec = results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, @@ -585,8 +591,11 @@ pub async fn access_shared( .await .map_err(|e| ApiError::internal(format!("Search failed: {e}")))?; - let items: Vec = - results.items.into_iter().map(MediaResponse::from).collect(); + let items: Vec = results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, diff --git a/crates/pinakes-server/src/routes/social.rs b/crates/pinakes-server/src/routes/social.rs index b270ae0..f5bc17a 100644 --- a/crates/pinakes-server/src/routes/social.rs +++ b/crates/pinakes-server/src/routes/social.rs @@ -125,7 +125,13 @@ pub async fn list_favorites( .storage .get_user_favorites(user_id, &Pagination::default()) .await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } pub async fn create_share_link( @@ -205,5 +211,6 @@ pub async fn access_shared_media( } state.storage.increment_share_views(&token).await?; let item = state.storage.get_media(link.media_id).await?; - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } diff --git a/crates/pinakes-ui/assets/css/main.css b/crates/pinakes-ui/assets/css/main.css index 30f105a..746e00c 100644 --- a/crates/pinakes-ui/assets/css/main.css +++ b/crates/pinakes-ui/assets/css/main.css @@ -1 +1 @@ -@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.plugin-col-constrained{width:var(--plugin-col-width)}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)} \ No newline at end of file +@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)}.plugin-page{padding:16px 24px;max-width:100%;overflow-x:hidden}.plugin-page-title{font-size:14px;font-weight:600;color:#dcdce4;margin:0 0 16px}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden}.plugin-card-header{padding:12px 16px;font-size:12px;font-weight:600;color:#dcdce4;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.plugin-card-content{padding:16px}.plugin-card-footer{padding:12px 16px;border-top:1px solid rgba(255,255,255,.09);background:#18181f}.plugin-heading{color:#dcdce4;margin:0;line-height:1.2}.plugin-heading.level-1{font-size:28px;font-weight:700}.plugin-heading.level-2{font-size:18px;font-weight:600}.plugin-heading.level-3{font-size:16px;font-weight:600}.plugin-heading.level-4{font-size:14px;font-weight:500}.plugin-heading.level-5{font-size:13px;font-weight:500}.plugin-heading.level-6{font-size:12px;font-weight:500}.plugin-text{margin:0;font-size:12px;color:#dcdce4;line-height:1.4}.plugin-text.text-secondary{color:#a0a0b8}.plugin-text.text-error{color:#d47070}.plugin-text.text-success{color:#3ec97a}.plugin-text.text-warning{color:#d4a037}.plugin-text.text-bold{font-weight:600}.plugin-text.text-italic{font-style:italic}.plugin-text.text-small{font-size:10px}.plugin-text.text-large{font-size:15px}.plugin-code{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px 24px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#dcdce4;overflow-x:auto;white-space:pre}.plugin-code code{font-family:inherit;font-size:inherit;color:inherit}.plugin-tabs{display:flex;flex-direction:column}.plugin-tab-list{display:flex;gap:2px;border-bottom:1px solid rgba(255,255,255,.09);margin-bottom:16px}.plugin-tab{padding:8px 20px;font-size:12px;font-weight:500;color:#a0a0b8;background:rgba(0,0,0,0);border:none;border-bottom:2px solid rgba(0,0,0,0);cursor:pointer;transition:color .1s,border-color .1s}.plugin-tab:hover{color:#dcdce4}.plugin-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.plugin-tab .tab-icon{margin-right:4px}.plugin-tab-panel:not(.active){display:none}.plugin-description-list-wrapper{width:100%}.plugin-description-list{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:0;padding:0}.plugin-description-list dt{font-size:10px;font-weight:500;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;padding:6px 0;white-space:nowrap}.plugin-description-list dd{font-size:12px;color:#dcdce4;padding:6px 0;margin:0;word-break:break-word}.plugin-description-list.horizontal{display:flex;flex-wrap:wrap;gap:16px 24px;display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr))}.plugin-description-list.horizontal dt{width:auto;padding:0}.plugin-description-list.horizontal dd{width:auto;padding:0}.plugin-description-list.horizontal dt,.plugin-description-list.horizontal dd{display:inline}.plugin-description-list.horizontal dt{font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:#6c6c84;margin-bottom:2px}.plugin-description-list.horizontal dd{font-size:13px;font-weight:600;color:#dcdce4}.plugin-data-table-wrapper{overflow-x:auto}.plugin-data-table{width:100%;border-collapse:collapse;font-size:12px}.plugin-data-table thead tr{border-bottom:1px solid rgba(255,255,255,.14)}.plugin-data-table thead th{padding:8px 12px;text-align:left;font-size:10px;font-weight:600;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}.plugin-data-table tbody tr{border-bottom:1px solid rgba(255,255,255,.06);transition:background .08s}.plugin-data-table tbody tr:hover{background:rgba(255,255,255,.03)}.plugin-data-table tbody tr:last-child{border-bottom:none}.plugin-data-table tbody td{padding:8px 12px;color:#dcdce4;vertical-align:middle}.plugin-col-constrained{width:var(--plugin-col-width)}.table-filter{margin-bottom:12px}.table-filter input{width:240px;padding:6px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px}.table-filter input::placeholder{color:#6c6c84}.table-filter input:focus{outline:none;border-color:#7c7ef5}.table-pagination{display:flex;align-items:center;gap:12px;padding:8px 0;font-size:12px;color:#a0a0b8}.row-actions{white-space:nowrap;width:1%}.row-actions .plugin-button{padding:4px 8px;font-size:10px;margin-right:4px}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.media-grid-item{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden;display:flex;flex-direction:column}.media-grid-img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block}.media-grid-no-img{width:100%;aspect-ratio:16/9;background:#26263a;display:flex;align-items:center;justify-content:center;font-size:10px;color:#6c6c84}.media-grid-caption{padding:8px 12px;font-size:10px;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.plugin-list{list-style:none;margin:0;padding:0}.plugin-list-item{padding:8px 0}.plugin-list-divider{border:none;border-top:1px solid rgba(255,255,255,.06);margin:0}.plugin-list-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px}.plugin-button{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border:1px solid rgba(255,255,255,.09);border-radius:5px;font-size:12px;font-weight:500;cursor:pointer;transition:background .08s,border-color .08s,color .08s;background:#1f1f28;color:#dcdce4}.plugin-button:disabled{opacity:.45;cursor:not-allowed}.plugin-button.btn-primary{background:#7c7ef5;border-color:#7c7ef5;color:#fff}.plugin-button.btn-primary:hover:not(:disabled){background:#8b8df7}.plugin-button.btn-secondary{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.plugin-button.btn-secondary:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-button.btn-tertiary{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#9698f7}.plugin-button.btn-tertiary:hover:not(:disabled){background:rgba(124,126,245,.15)}.plugin-button.btn-danger{background:rgba(0,0,0,0);border-color:rgba(228,88,88,.2);color:#d47070}.plugin-button.btn-danger:hover:not(:disabled){background:rgba(228,88,88,.06)}.plugin-button.btn-success{background:rgba(0,0,0,0);border-color:rgba(62,201,122,.2);color:#3ec97a}.plugin-button.btn-success:hover:not(:disabled){background:rgba(62,201,122,.08)}.plugin-button.btn-ghost{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#a0a0b8}.plugin-button.btn-ghost:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:50%;font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}.plugin-badge.badge-default,.plugin-badge.badge-neutral{background:rgba(255,255,255,.04);color:#a0a0b8}.plugin-badge.badge-primary{background:rgba(124,126,245,.15);color:#9698f7}.plugin-badge.badge-secondary{background:rgba(255,255,255,.03);color:#dcdce4}.plugin-badge.badge-success{background:rgba(62,201,122,.08);color:#3ec97a}.plugin-badge.badge-warning{background:rgba(212,160,55,.06);color:#d4a037}.plugin-badge.badge-error{background:rgba(228,88,88,.06);color:#d47070}.plugin-badge.badge-info{background:rgba(99,102,241,.08);color:#9698f7}.plugin-form{display:flex;flex-direction:column;gap:16px}.form-field{display:flex;flex-direction:column;gap:6px}.form-field label{font-size:12px;font-weight:500;color:#dcdce4}.form-field input,.form-field textarea,.form-field select{padding:8px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px;font-family:inherit}.form-field input::placeholder,.form-field textarea::placeholder,.form-field select::placeholder{color:#6c6c84}.form-field input:focus,.form-field textarea:focus,.form-field select:focus{outline:none;border-color:#7c7ef5;box-shadow:0 0 0 2px rgba(124,126,245,.15)}.form-field textarea{min-height:80px;resize:vertical}.form-field select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}.form-help{margin:0;font-size:10px;color:#6c6c84}.form-actions{display:flex;gap:12px;padding-top:8px}.required{color:#e45858}.plugin-link{color:#9698f7;text-decoration:none}.plugin-link:hover{text-decoration:underline}.plugin-link-blocked{color:#6c6c84;text-decoration:line-through;cursor:not-allowed}.plugin-progress{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;height:8px;overflow:hidden;display:flex;align-items:center;gap:8px}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-progress-label{font-size:10px;color:#a0a0b8;white-space:nowrap;flex-shrink:0}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)}.plugin-chart .chart-title{font-size:13px;font-weight:600;color:#dcdce4;margin-bottom:8px}.plugin-chart .chart-x-label,.plugin-chart .chart-y-label{font-size:10px;color:#6c6c84;margin-bottom:4px}.plugin-chart .chart-data-table{overflow-x:auto}.plugin-chart .chart-no-data{padding:24px;text-align:center;color:#6c6c84;font-size:12px}.plugin-loading{padding:16px;color:#a0a0b8;font-size:12px;font-style:italic}.plugin-error{padding:12px 16px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:5px;color:#d47070;font-size:12px}.plugin-feedback{position:sticky;bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;border-radius:7px;font-size:12px;z-index:300;box-shadow:0 4px 20px rgba(0,0,0,.45)}.plugin-feedback.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.plugin-feedback.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#d47070}.plugin-feedback-dismiss{background:rgba(0,0,0,0);border:none;color:inherit;font-size:14px;cursor:pointer;line-height:1;padding:0;opacity:.7}.plugin-feedback-dismiss:hover{opacity:1}.plugin-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;z-index:100}.plugin-modal{position:relative;background:#1f1f28;border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:32px;min-width:380px;max-width:640px;max-height:80vh;overflow-y:auto;box-shadow:0 4px 20px rgba(0,0,0,.45);z-index:200}.plugin-modal-close{position:absolute;top:16px;right:16px;background:rgba(0,0,0,0);border:none;color:#a0a0b8;font-size:14px;cursor:pointer;line-height:1;padding:4px;border-radius:5px}.plugin-modal-close:hover{background:rgba(255,255,255,.04);color:#dcdce4} \ No newline at end of file diff --git a/crates/pinakes-ui/assets/styles/_plugins.scss b/crates/pinakes-ui/assets/styles/_plugins.scss index c44762a..915180e 100644 --- a/crates/pinakes-ui/assets/styles/_plugins.scss +++ b/crates/pinakes-ui/assets/styles/_plugins.scss @@ -7,6 +7,20 @@ // The layout rules here consume those properties via var() so the renderer // never injects full CSS rule strings. +// Page wrapper +.plugin-page { + padding: $space-8 $space-12; + max-width: 100%; + overflow-x: hidden; +} + +.plugin-page-title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + color: $text-0; + margin: 0 0 $space-8; +} + // Container: vertical flex column with configurable gap and padding. .plugin-container { display: flex; @@ -65,20 +79,568 @@ min-width: 0; } -// Media grid reuses the same column/gap variables as .plugin-grid. +// Card +.plugin-card { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius-md; + overflow: hidden; +} + +.plugin-card-header { + padding: $space-6 $space-8; + font-size: $font-size-md; + font-weight: $font-weight-semibold; + color: $text-0; + border-bottom: 1px solid $border; + background: $bg-3; +} + +.plugin-card-content { + padding: $space-8; +} + +.plugin-card-footer { + padding: $space-6 $space-8; + border-top: 1px solid $border; + background: $bg-1; +} + +// Typography +.plugin-heading { + color: $text-0; + margin: 0; + line-height: $line-height-tight; + + &.level-1 { font-size: $font-size-6xl; font-weight: $font-weight-bold; } + &.level-2 { font-size: $font-size-4xl; font-weight: $font-weight-semibold; } + &.level-3 { font-size: $font-size-3xl; font-weight: $font-weight-semibold; } + &.level-4 { font-size: $font-size-xl; font-weight: $font-weight-medium; } + &.level-5 { font-size: $font-size-lg; font-weight: $font-weight-medium; } + &.level-6 { font-size: $font-size-md; font-weight: $font-weight-medium; } +} + +.plugin-text { + margin: 0; + font-size: $font-size-md; + color: $text-0; + line-height: $line-height-normal; + + &.text-secondary { color: $text-1; } + &.text-error { color: $error-text; } + &.text-success { color: $success; } + &.text-warning { color: $warning; } + &.text-bold { font-weight: $font-weight-semibold; } + &.text-italic { font-style: italic; } + &.text-small { font-size: $font-size-sm; } + &.text-large { font-size: $font-size-2xl; } +} + +.plugin-code { + background: $bg-1; + border: 1px solid $border; + border-radius: $radius; + padding: $space-8 $space-12; + font-family: $font-family-mono; + font-size: $font-size-md; + color: $text-0; + overflow-x: auto; + white-space: pre; + + code { + font-family: inherit; + font-size: inherit; + color: inherit; + } +} + +// Tabs +.plugin-tabs { + display: flex; + flex-direction: column; +} + +.plugin-tab-list { + display: flex; + gap: 2px; + border-bottom: 1px solid $border; + margin-bottom: $space-8; +} + +.plugin-tab { + padding: $space-4 $space-10; + font-size: $font-size-md; + font-weight: $font-weight-medium; + color: $text-1; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: color $transition-base, border-color $transition-base; + + &:hover { + color: $text-0; + } + + &.active { + color: $accent-text; + border-bottom-color: $accent; + } + + .tab-icon { + margin-right: $space-2; + } +} + +.plugin-tab-panels {} + +.plugin-tab-panel { + &:not(.active) { display: none; } +} + +// Description list +.plugin-description-list-wrapper { + width: 100%; +} + +.plugin-description-list { + display: grid; + grid-template-columns: max-content 1fr; + gap: $space-2 $space-8; + margin: 0; + padding: 0; + + dt { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-1; + text-transform: uppercase; + letter-spacing: $letter-spacing-uppercase; + padding: $space-3 0; + white-space: nowrap; + } + + dd { + font-size: $font-size-md; + color: $text-0; + padding: $space-3 0; + margin: 0; + word-break: break-word; + } + + &.horizontal { + display: flex; + flex-wrap: wrap; + gap: $space-8 $space-12; + + dt { + width: auto; + padding: 0; + } + + dd { + width: auto; + padding: 0; + } + + // Pair dt+dd side by side + dt, dd { + display: inline; + } + + // Each dt/dd pair sits in its own flex group via a wrapper approach. + // Since we can't group them, use a two-column repeat trick instead. + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + + dt { + font-size: $font-size-xs; + text-transform: uppercase; + letter-spacing: $letter-spacing-uppercase; + color: $text-2; + margin-bottom: $space-1; + } + + dd { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-0; + } + } +} + +// Data table +.plugin-data-table-wrapper { + overflow-x: auto; +} + +.plugin-data-table { + width: 100%; + border-collapse: collapse; + font-size: $font-size-md; + + thead { + tr { + border-bottom: 1px solid $border-strong; + } + + th { + padding: $space-4 $space-6; + text-align: left; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: $text-1; + text-transform: uppercase; + letter-spacing: $letter-spacing-uppercase; + white-space: nowrap; + } + } + + tbody { + tr { + border-bottom: 1px solid $border-subtle; + transition: background $transition-fast; + + &:hover { + background: $overlay-light; + } + + &:last-child { + border-bottom: none; + } + } + + td { + padding: $space-4 $space-6; + color: $text-0; + vertical-align: middle; + } + } +} + +// Table column with a plugin-specified fixed width. +.plugin-col-constrained { + width: var(--plugin-col-width); +} + +.table-filter { + margin-bottom: $space-6; + + input { + width: 240px; + padding: $space-3 $space-6; + background: $bg-1; + border: 1px solid $border; + border-radius: $radius; + color: $text-0; + font-size: $font-size-md; + + &::placeholder { color: $text-2; } + &:focus { + outline: none; + border-color: $accent; + } + } +} + +.table-pagination { + display: flex; + align-items: center; + gap: $space-6; + padding: $space-4 0; + font-size: $font-size-md; + color: $text-1; +} + +.row-actions { + white-space: nowrap; + width: 1%; + + .plugin-button { + padding: $space-2 $space-4; + font-size: $font-size-sm; + margin-right: $space-2; + } +} + +// Media grid: reuses column/gap variables from plugin-grid. .plugin-media-grid { display: grid; grid-template-columns: repeat(var(--plugin-columns, 2), 1fr); gap: var(--plugin-gap, 8px); } -// Table column with a plugin-specified fixed width. -// The width is passed as --plugin-col-width on the th element. -.plugin-col-constrained { - width: var(--plugin-col-width); +.media-grid-item { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius-md; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.media-grid-img { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + display: block; +} + +.media-grid-no-img { + width: 100%; + aspect-ratio: 16 / 9; + background: $bg-3; + display: flex; + align-items: center; + justify-content: center; + font-size: $font-size-sm; + color: $text-2; +} + +.media-grid-caption { + padding: $space-4 $space-6; + font-size: $font-size-sm; + color: $text-0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// List +.plugin-list-wrapper {} + +.plugin-list { + list-style: none; + margin: 0; + padding: 0; +} + +.plugin-list-item { + padding: $space-4 0; +} + +.plugin-list-divider { + border: none; + border-top: 1px solid $border-subtle; + margin: 0; +} + +.plugin-list-empty { + padding: $space-8; + text-align: center; + color: $text-2; + font-size: $font-size-md; +} + +// Interactive: buttons +.plugin-button { + display: inline-flex; + align-items: center; + gap: $space-3; + padding: $space-4 $space-8; + border: 1px solid $border; + border-radius: $radius; + font-size: $font-size-md; + font-weight: $font-weight-medium; + cursor: pointer; + transition: background $transition-fast, border-color $transition-fast, + color $transition-fast; + background: $bg-2; + color: $text-0; + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &.btn-primary { + background: $accent; + border-color: $accent; + color: #fff; + + &:hover:not(:disabled) { background: $accent-hover; } + } + + &.btn-secondary { + background: $bg-3; + border-color: $border-strong; + color: $text-0; + + &:hover:not(:disabled) { background: $overlay-medium; } + } + + &.btn-tertiary { + background: transparent; + border-color: transparent; + color: $accent-text; + + &:hover:not(:disabled) { background: $accent-dim; } + } + + &.btn-danger { + background: transparent; + border-color: $error-border; + color: $error-text; + + &:hover:not(:disabled) { background: $error-bg; } + } + + &.btn-success { + background: transparent; + border-color: $success-border; + color: $success; + + &:hover:not(:disabled) { background: $success-bg; } + } + + &.btn-ghost { + background: transparent; + border-color: transparent; + color: $text-1; + + &:hover:not(:disabled) { background: $btn-ghost-hover; } + } +} + +// Badges +.plugin-badge { + display: inline-flex; + align-items: center; + padding: $space-1 $space-4; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + letter-spacing: $letter-spacing-uppercase; + text-transform: uppercase; + + &.badge-default, &.badge-neutral { + background: $overlay-medium; + color: $text-1; + } + + &.badge-primary { + background: $accent-dim; + color: $accent-text; + } + + &.badge-secondary { + background: $overlay-light; + color: $text-0; + } + + &.badge-success { + background: $success-bg; + color: $success; + } + + &.badge-warning { + background: $warning-bg; + color: $warning; + } + + &.badge-error { + background: $error-bg; + color: $error-text; + } + + &.badge-info { + background: $info-bg; + color: $accent-text; + } +} + +// Form +.plugin-form { + display: flex; + flex-direction: column; + gap: $space-8; +} + +.form-field { + display: flex; + flex-direction: column; + gap: $space-3; + + label { + font-size: $font-size-md; + font-weight: $font-weight-medium; + color: $text-0; + } + + input, textarea, select { + padding: $space-4 $space-6; + background: $bg-1; + border: 1px solid $border; + border-radius: $radius; + color: $text-0; + font-size: $font-size-md; + font-family: inherit; + + &::placeholder { color: $text-2; } + + &:focus { + outline: none; + border-color: $accent; + box-shadow: 0 0 0 2px $accent-dim; + } + } + + textarea { + min-height: 80px; + resize: vertical; + } + + select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right $space-6 center; + padding-right: $space-16; + } +} + +.form-help { + margin: 0; + font-size: $font-size-sm; + color: $text-2; +} + +.form-actions { + display: flex; + gap: $space-6; + padding-top: $space-4; +} + +.required { + color: $error; +} + +// Link +.plugin-link { + color: $accent-text; + text-decoration: none; + + &:hover { text-decoration: underline; } +} + +.plugin-link-blocked { + color: $text-2; + text-decoration: line-through; + cursor: not-allowed; +} + +// Progress +.plugin-progress { + background: $bg-1; + border: 1px solid $border; + border-radius: $radius; + height: 8px; + overflow: hidden; + display: flex; + align-items: center; + gap: $space-4; } -// Progress bar: the fill element carries --plugin-progress. .plugin-progress-bar { height: 100%; background: $accent; @@ -87,8 +649,140 @@ width: var(--plugin-progress, 0%); } +.plugin-progress-label { + font-size: $font-size-sm; + color: $text-1; + white-space: nowrap; + flex-shrink: 0; +} + // Chart wrapper: height is driven by --plugin-chart-height. .plugin-chart { overflow: auto; height: var(--plugin-chart-height, 200px); + + .chart-title { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-0; + margin-bottom: $space-4; + } + + .chart-x-label, .chart-y-label { + font-size: $font-size-sm; + color: $text-2; + margin-bottom: $space-2; + } + + .chart-data-table { + overflow-x: auto; + } + + .chart-no-data { + padding: $space-12; + text-align: center; + color: $text-2; + font-size: $font-size-md; + } +} + +// Loading / error states +.plugin-loading { + padding: $space-8; + color: $text-1; + font-size: $font-size-md; + font-style: italic; +} + +.plugin-error { + padding: $space-6 $space-8; + background: $error-bg; + border: 1px solid $error-border; + border-radius: $radius; + color: $error-text; + font-size: $font-size-md; +} + +// Feedback toast +.plugin-feedback { + position: sticky; + bottom: $space-8; + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-8; + padding: $space-6 $space-8; + border-radius: $radius-md; + font-size: $font-size-md; + z-index: $z-toast; + box-shadow: $shadow-lg; + + &.success { + background: $success-bg; + border: 1px solid $success-border; + color: $success; + } + + &.error { + background: $error-bg; + border: 1px solid $error-border; + color: $error-text; + } +} + +.plugin-feedback-dismiss { + background: transparent; + border: none; + color: inherit; + font-size: $font-size-xl; + cursor: pointer; + line-height: 1; + padding: 0; + opacity: 0.7; + + &:hover { opacity: 1; } +} + +// Modal +.plugin-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: $z-modal-backdrop; +} + +.plugin-modal { + position: relative; + background: $bg-2; + border: 1px solid $border-strong; + border-radius: $radius-xl; + padding: $space-16; + min-width: 380px; + max-width: 640px; + max-height: 80vh; + overflow-y: auto; + box-shadow: $shadow-lg; + z-index: $z-modal; +} + +.plugin-modal-close { + position: absolute; + top: $space-8; + right: $space-8; + background: transparent; + border: none; + color: $text-1; + font-size: $font-size-xl; + cursor: pointer; + line-height: 1; + padding: $space-2; + border-radius: $radius; + + &:hover { + background: $overlay-medium; + color: $text-0; + } } diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index 11026b9..43699d2 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -369,11 +369,13 @@ pub fn App() -> Element { spawn(async move { let js: String = vars .iter() - .map(|(k, v)| { - format!( - "document.documentElement.style.setProperty('{}','{}');", - k, v - ) + .filter_map(|(k, v)| { + let k_js = serde_json::to_string(k).ok()?; + let v_js = serde_json::to_string(v).ok()?; + Some(format!( + "document.documentElement.style.setProperty({k_js},\ + {v_js});" + )) }) .collect(); let _ = document::eval(&js).await; @@ -849,17 +851,6 @@ pub fn App() -> Element { } } } - { - let sync_time_opt = plugin_registry - .read() - .last_refresh() - .map(|ts| ts.format("%H:%M").to_string()); - rsx! { - if let Some(sync_time) = sync_time_opt { - div { class: "nav-sync-time", "Synced {sync_time}" } - } - } - } } } diff --git a/crates/pinakes-ui/src/plugin_ui/actions.rs b/crates/pinakes-ui/src/plugin_ui/actions.rs index 8c9eb64..1c6f553 100644 --- a/crates/pinakes-ui/src/plugin_ui/actions.rs +++ b/crates/pinakes-ui/src/plugin_ui/actions.rs @@ -96,14 +96,11 @@ async fn execute_inline_action( action: &ActionDefinition, form_data: Option<&serde_json::Value>, ) -> Result { - // Build URL from path - let url = action.path.clone(); - // Merge action params with form data into query string for GET, body for // others let method = to_reqwest_method(&action.method); - let mut request = client.raw_request(method.clone(), &url); + let mut request = client.raw_request(method.clone(), &action.path); // For GET, merge params into query string; for mutating methods, send as // JSON body diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs index d3f42dc..2244fe6 100644 --- a/crates/pinakes-ui/src/plugin_ui/data.rs +++ b/crates/pinakes-ui/src/plugin_ui/data.rs @@ -2,7 +2,10 @@ //! //! Provides data fetching and caching for plugin data sources. -use std::{collections::HashMap, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + time::Duration, +}; use dioxus::prelude::*; use dioxus_core::Task; @@ -15,7 +18,7 @@ use crate::client::ApiClient; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PluginPageData { data: HashMap, - loading: HashMap, + loading: HashSet, errors: HashMap, } @@ -29,13 +32,13 @@ impl PluginPageData { /// Check if a source is currently loading #[must_use] pub fn is_loading(&self, source: &str) -> bool { - self.loading.get(source).copied().unwrap_or(false) + self.loading.contains(source) } /// Get error for a specific source #[must_use] - pub fn error(&self, source: &str) -> Option<&String> { - self.errors.get(source) + pub fn error(&self, source: &str) -> Option<&str> { + self.errors.get(source).map(String::as_str) } /// Check if there is data for a specific source @@ -52,7 +55,7 @@ impl PluginPageData { /// Set loading state for a source pub fn set_loading(&mut self, source: &str, loading: bool) { if loading { - self.loading.insert(source.to_string(), true); + self.loading.insert(source.to_string()); self.errors.remove(source); } else { self.loading.remove(source); @@ -161,9 +164,10 @@ async fn fetch_endpoint( /// /// Endpoint sources are deduplicated by `(path, method, params)`: if multiple /// sources share the same triplet, a single HTTP request is made and the raw -/// response is shared, with each source's own `transform` applied independently. -/// All unique Endpoint and Static sources are fetched concurrently. Transform -/// sources are applied after, in iteration order, against the full result set. +/// response is shared, with each source's own `transform` applied +/// independently. All unique Endpoint and Static sources are fetched +/// concurrently. Transform sources are applied after, in iteration order, +/// against the full result set. /// /// # Errors /// @@ -263,8 +267,15 @@ pub async fn fetch_page_data( .. } => { let empty_ctx = serde_json::json!({}); - fetch_endpoint(&client, path, method.clone(), params, &empty_ctx, &allowed) - .await? + fetch_endpoint( + &client, + path, + method.clone(), + params, + &empty_ctx, + &allowed, + ) + .await? }, DataSource::Static { value } => value.clone(), DataSource::Transform { .. } => unreachable!(), @@ -296,21 +307,60 @@ pub async fn fetch_page_data( } } - // Process Transform sources sequentially; they reference results above. - for (name, source) in data_sources { - if let DataSource::Transform { - source_name, - expression, - } = source - { - let ctx = serde_json::Value::Object( - results - .iter() - .map(|(k, v): (&String, &serde_json::Value)| (k.clone(), v.clone())) - .collect(), + // Process Transform sources in dependency order. HashMap iteration order is + // non-deterministic, so a Transform referencing another Transform could see + // null if the upstream was not yet resolved. The pending loop below defers + // any Transform whose upstream is not yet in results, making progress on + // each pass until all are resolved. UiPage::validate guarantees no cycles, + // so the loop always terminates. + let mut pending: Vec<(&String, &String, &Expression)> = data_sources + .iter() + .filter_map(|(name, source)| { + match source { + DataSource::Transform { + source_name, + expression, + } => Some((name, source_name, expression)), + _ => None, + } + }) + .collect(); + + while !pending.is_empty() { + let prev_len = pending.len(); + let mut i = 0; + while i < pending.len() { + let (name, source_name, expression) = pending[i]; + if results.contains_key(source_name.as_str()) { + let ctx = serde_json::Value::Object( + results + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ); + results.insert(name.clone(), evaluate_expression(expression, &ctx)); + pending.swap_remove(i); + } else { + i += 1; + } + } + if pending.len() == prev_len { + // No progress: upstream source is missing (should be caught by + // UiPage::validate, but handled defensively here). + tracing::warn!( + "plugin transform dependency unresolvable; processing remaining in \ + iteration order" ); - let _ = source_name; // accessible in ctx by its key - results.insert(name.clone(), evaluate_expression(expression, &ctx)); + for (name, _, expression) in pending { + let ctx = serde_json::Value::Object( + results + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ); + results.insert(name.clone(), evaluate_expression(expression, &ctx)); + } + break; } } @@ -446,7 +496,7 @@ mod tests { // Test error state data.set_error("error".to_string(), "oops".to_string()); - assert_eq!(data.error("error"), Some(&"oops".to_string())); + assert_eq!(data.error("error"), Some("oops")); } #[test] @@ -522,7 +572,9 @@ mod tests { value: serde_json::json!(true), }); - let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap(); + let results = super::fetch_page_data(&client, &sources, &[]) + .await + .unwrap(); assert_eq!(results["nums"], serde_json::json!([1, 2, 3])); assert_eq!(results["flag"], serde_json::json!(true)); } @@ -544,7 +596,9 @@ mod tests { value: serde_json::json!({"ok": true}), }); - let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap(); + let results = super::fetch_page_data(&client, &sources, &[]) + .await + .unwrap(); assert_eq!(results["raw"], serde_json::json!({"ok": true})); // derived should return the value of "raw" from context assert_eq!(results["derived"], serde_json::json!({"ok": true})); @@ -566,13 +620,13 @@ mod tests { expression: Expression::Literal(serde_json::json!("constant")), }); - let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap(); + let results = super::fetch_page_data(&client, &sources, &[]) + .await + .unwrap(); // A Literal expression returns the literal value, not the source data assert_eq!(results["derived"], serde_json::json!("constant")); } - // Test: multiple Static sources with the same value each get their own - // result; dedup logic does not collapse distinct-named Static sources. #[tokio::test] async fn test_fetch_page_data_deduplicates_identical_endpoints() { use pinakes_plugin_api::DataSource; @@ -589,18 +643,18 @@ mod tests { sources.insert("b".to_string(), DataSource::Static { value: serde_json::json!(1), }); - let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap(); + let results = super::fetch_page_data(&client, &sources, &[]) + .await + .unwrap(); assert_eq!(results["a"], serde_json::json!(1)); assert_eq!(results["b"], serde_json::json!(1)); assert_eq!(results.len(), 2); } - // Test: Endpoint sources with identical (path, method, params) but different - // transform expressions each get a correctly transformed result. Because the - // test runs without a real server the path is checked against the allowlist - // before any network call, so we verify the dedup key grouping through the - // allowlist rejection path: both names should see the same error message, - // proving they were grouped and the single rejection propagates to all names. + // Verifies that endpoint sources with identical (path, method, params) are + // deduplicated correctly. Because there is no real server, the allowlist + // rejection fires before any network call; both names seeing the same error + // proves they were grouped and that the single rejection propagated to all. #[tokio::test] async fn test_dedup_groups_endpoint_sources_with_same_key() { use pinakes_plugin_api::{DataSource, Expression, HttpMethod}; @@ -640,14 +694,12 @@ mod tests { ); } - // Test: multiple Transform sources referencing the same upstream Static source - // with different expressions each receive their independently transformed - // result. This exercises the transform fan-out behavior that mirrors what - // the Endpoint dedup group does after a single shared HTTP request completes: - // each member of a group applies its own transform to the shared raw value. + // Verifies the transform fan-out behavior: each member of a dedup group + // applies its own transform to the shared raw value independently. This + // mirrors what Endpoint dedup does after a single shared HTTP request. // - // Testing the Endpoint dedup success path with real per-member transforms - // requires a mock HTTP server and belongs in an integration test. + // Testing Endpoint dedup with real per-member transforms requires a mock HTTP + // server and belongs in an integration test. #[tokio::test] async fn test_dedup_transform_applied_per_source() { use pinakes_plugin_api::{DataSource, Expression}; @@ -670,8 +722,9 @@ mod tests { expression: Expression::Path("raw_data.name".to_string()), }); - let results = - super::fetch_page_data(&client, &sources, &[]).await.unwrap(); + let results = super::fetch_page_data(&client, &sources, &[]) + .await + .unwrap(); assert_eq!( results["raw_data"], serde_json::json!({"count": 42, "name": "test"}) @@ -681,8 +734,6 @@ mod tests { assert_eq!(results.len(), 3); } - // Test: fetch_page_data returns an error when the endpoint data source path is - // not listed in the allowed_endpoints slice. #[tokio::test] async fn test_endpoint_blocked_when_not_in_allowlist() { use pinakes_plugin_api::{DataSource, HttpMethod}; @@ -705,7 +756,8 @@ mod tests { assert!( result.is_err(), - "fetch_page_data must return Err when endpoint is not in allowed_endpoints" + "fetch_page_data must return Err when endpoint is not in \ + allowed_endpoints" ); let msg = result.unwrap_err(); assert!( diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs index fb3d1b6..8fde3d0 100644 --- a/crates/pinakes-ui/src/plugin_ui/registry.rs +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -35,28 +35,19 @@ pub struct PluginPage { pub allowed_endpoints: Vec, } -impl PluginPage { - /// The canonical route for this page, taken directly from the page schema. - pub fn full_route(&self) -> String { - self.page.route.clone() - } -} - /// Registry of all plugin-provided UI pages and widgets /// /// 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, + client: ApiClient, /// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage` - pages: HashMap<(String, String), PluginPage>, + pages: HashMap<(String, String), PluginPage>, /// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget` - widgets: Vec<(String, UiWidget)>, + widgets: Vec<(String, UiWidget)>, /// Merged CSS custom property overrides from all enabled plugins - theme_vars: HashMap, - /// Last refresh timestamp - last_refresh: Option>, + theme_vars: HashMap, } impl PluginRegistry { @@ -67,7 +58,6 @@ impl PluginRegistry { pages: HashMap::new(), widgets: Vec::new(), theme_vars: HashMap::new(), - last_refresh: None, } } @@ -109,14 +99,11 @@ impl PluginRegistry { ); return; } - self.pages.insert( - (plugin_id.clone(), page_id), - PluginPage { - plugin_id, - page, - allowed_endpoints, - }, - ); + self.pages.insert((plugin_id.clone(), page_id), PluginPage { + plugin_id, + page, + allowed_endpoints, + }); } /// Get a specific page by plugin ID and page ID @@ -179,7 +166,7 @@ impl PluginRegistry { self .pages .values() - .map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route())) + .map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.page.route.clone())) .collect() } @@ -207,21 +194,17 @@ impl PluginRegistry { } match self.client.get_plugin_ui_theme_extensions().await { Ok(vars) => tmp.theme_vars = vars, - Err(e) => tracing::warn!("Failed to refresh plugin theme extensions: {e}"), + Err(e) => { + tracing::warn!("Failed to refresh plugin theme extensions: {e}") + }, } // Atomic swap: no window where the registry appears empty. self.pages = tmp.pages; self.widgets = tmp.widgets; self.theme_vars = tmp.theme_vars; - self.last_refresh = Some(chrono::Utc::now()); Ok(()) } - - /// Get last refresh time - pub const fn last_refresh(&self) -> Option> { - self.last_refresh - } } impl Default for PluginRegistry { @@ -354,7 +337,6 @@ mod tests { let registry = PluginRegistry::default(); assert!(registry.is_empty()); assert_eq!(registry.all_pages().len(), 0); - assert!(registry.last_refresh().is_none()); } #[test] @@ -367,7 +349,7 @@ mod tests { } #[test] - fn test_page_full_route() { + fn test_page_route() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); registry.register_page( @@ -376,9 +358,7 @@ mod tests { vec![], ); let plugin_page = registry.get_page("my-plugin", "demo").unwrap(); - // full_route() returns page.route directly; create_test_page sets it as - // "/plugins/test/{id}" - assert_eq!(plugin_page.full_route(), "/plugins/test/demo"); + assert_eq!(plugin_page.page.route, "/plugins/test/demo"); } #[test] @@ -418,8 +398,16 @@ mod tests { 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"), vec![]); - registry.register_page("p2".to_string(), create_test_page("b", "B"), vec![]); + registry.register_page( + "p1".to_string(), + create_test_page("a", "A"), + vec![], + ); + registry.register_page( + "p2".to_string(), + create_test_page("b", "B"), + vec![], + ); let pages = registry.all_pages(); assert_eq!(pages.len(), 2); @@ -536,7 +524,11 @@ mod tests { assert_eq!(registry.all_pages().len(), 0); // Valid page; should still register fine - registry.register_page("p".to_string(), create_test_page("good", "Good"), vec![]); + registry.register_page( + "p".to_string(), + create_test_page("good", "Good"), + vec![], + ); assert_eq!(registry.all_pages().len(), 1); } diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs index e8372fd..fa62f65 100644 --- a/crates/pinakes-ui/src/plugin_ui/renderer.rs +++ b/crates/pinakes-ui/src/plugin_ui/renderer.rs @@ -110,8 +110,12 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element { modal, local_state, }; - let page_data = - use_plugin_data(props.client, data_sources, refresh, props.allowed_endpoints); + let page_data = use_plugin_data( + props.client, + data_sources, + refresh, + props.allowed_endpoints, + ); // Consume pending navigation requests and forward to the parent use_effect(move || { @@ -151,7 +155,7 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element { onclick: move |_| modal.set(None), "×" } - { render_element(&elem, &page_data.read(), &HashMap::new(), ctx) } + { render_element(&elem, &page_data.read(), &actions, ctx) } } } } @@ -318,44 +322,37 @@ fn PluginDataTable(props: PluginDataTableProps) -> Element { let row_val = row; rsx! { tr { - for col in props.columns.clone() { + for col in &props.columns { td { "{extract_cell(&row_val, &col.key)}" } } if !props.row_actions.is_empty() { td { class: "row-actions", - for act in props.row_actions.clone() { + for act in &props.row_actions { { let action = act.action.clone(); let row_data = row_val.clone(); let variant_class = button_variant_class(&act.variant); let page_actions = props.actions.clone(); - let success_msg: Option = - match &act.action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => props - .actions - .get(name) - .and_then(|a| { - a.success_message.clone() - }), - ActionRef::Inline(a) => { - a.success_message.clone() - }, - }; - let error_msg: Option = - match &act.action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => props - .actions - .get(name) - .and_then(|a| { - a.error_message.clone() - }), - ActionRef::Inline(a) => { - a.error_message.clone() - }, - }; + let (success_msg, error_msg): ( + Option, + Option, + ) = match &act.action { + ActionRef::Special(_) => (None, None), + ActionRef::Name(name) => props + .actions + .get(name) + .map_or((None, None), |a| { + ( + a.success_message.clone(), + a.error_message.clone(), + ) + }), + ActionRef::Inline(a) => ( + a.success_message.clone(), + a.error_message.clone(), + ), + }; let ctx = props.ctx; // Pre-compute data JSON at render time to // avoid moving props.data into closures. @@ -489,7 +486,8 @@ pub fn render_element( || "0".to_string(), |p| format!("{}px {}px {}px {}px", p[0], p[1], p[2], p[3]), ); - let style = format!("--plugin-gap:{gap}px;--plugin-padding:{padding_css};"); + let style = + format!("--plugin-gap:{gap}px;--plugin-padding:{padding_css};"); rsx! { div { class: "plugin-container", @@ -710,7 +708,8 @@ pub fn render_element( } else if let Some(arr) = items.and_then(|v| v.as_array()) { for item in arr { { - let url_opt = media_grid_image_url(item); + let base = ctx.client.peek().base_url().to_string(); + let url_opt = media_grid_image_url(item, &base); let label = media_grid_label(item); rsx! { div { class: "media-grid-item", @@ -797,7 +796,16 @@ pub fn render_element( .map(|obj| { obj .iter() - .map(|(k, v)| (k.clone(), value_to_display_string(v))) + .filter_map(|(k, v)| { + match v { + // Skip nested objects and arrays; they are not meaningful as + // single-line description terms. + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + None + }, + _ => Some((format_key_name(k), value_to_display_string(v))), + } + }) .collect() }) .unwrap_or_default(); @@ -829,20 +837,18 @@ pub fn render_element( let variant_class = button_variant_class(variant); let action_ref = action.clone(); let page_actions = actions.clone(); - let success_msg: Option = match action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => { - actions.get(name).and_then(|a| a.success_message.clone()) - }, - ActionRef::Inline(a) => a.success_message.clone(), - }; - let error_msg: Option = match action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => { - actions.get(name).and_then(|a| a.error_message.clone()) - }, - ActionRef::Inline(a) => a.error_message.clone(), - }; + let (success_msg, error_msg): (Option, Option) = + match action { + ActionRef::Special(_) => (None, None), + ActionRef::Name(name) => { + actions.get(name).map_or((None, None), |a| { + (a.success_message.clone(), a.error_message.clone()) + }) + }, + ActionRef::Inline(a) => { + (a.success_message.clone(), a.error_message.clone()) + }, + }; let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { button { @@ -904,20 +910,18 @@ pub fn render_element( } => { let action_ref = submit_action.clone(); let page_actions = actions.clone(); - let success_msg: Option = match submit_action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => { - actions.get(name).and_then(|a| a.success_message.clone()) - }, - ActionRef::Inline(a) => a.success_message.clone(), - }; - let error_msg: Option = match submit_action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => { - actions.get(name).and_then(|a| a.error_message.clone()) - }, - ActionRef::Inline(a) => a.error_message.clone(), - }; + let (success_msg, error_msg): (Option, Option) = + match submit_action { + ActionRef::Special(_) => (None, None), + ActionRef::Name(name) => { + actions.get(name).map_or((None, None), |a| { + (a.success_message.clone(), a.error_message.clone()) + }) + }, + ActionRef::Inline(a) => { + (a.success_message.clone(), a.error_message.clone()) + }, + }; let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { form { @@ -1050,7 +1054,7 @@ pub fn render_element( max, show_percentage, } => { - let eval_ctx = data.as_json(); + let eval_ctx = build_ctx(data, &ctx.local_state.read()); let pct = evaluate_expression_as_f64(value, &eval_ctx); let fraction = if *max > 0.0 { (pct / max).clamp(0.0, 1.0) @@ -1096,8 +1100,6 @@ pub fn render_element( } => { let chart_class = chart_type_class(chart_type); let chart_data = data.get(source_key).cloned(); - let x_label = x_axis_label.as_deref().unwrap_or("").to_string(); - let y_label = y_axis_label.as_deref().unwrap_or("").to_string(); rsx! { div { class: "plugin-chart {chart_class}", @@ -1111,7 +1113,7 @@ pub fn render_element( if let Some(x) = x_axis_label { div { class: "chart-x-label", "{x}" } } if let Some(y) = y_axis_label { div { class: "chart-y-label", "{y}" } } div { class: "chart-data-table", - { render_chart_data(chart_data.as_ref(), &x_label, &y_label) } + { render_chart_data(chart_data.as_ref(), x_axis_label.as_deref().unwrap_or(""), y_axis_label.as_deref().unwrap_or("")) } } } } @@ -1124,7 +1126,7 @@ pub fn render_element( then, else_element, } => { - let eval_ctx = data.as_json(); + let eval_ctx = build_ctx(data, &ctx.local_state.read()); if evaluate_expression_as_bool(condition, &eval_ctx) { render_element(then, data, actions, ctx) } else if let Some(else_el) = else_element { @@ -1252,7 +1254,10 @@ fn render_chart_data( // MediaGrid helpers /// Probe a JSON object for common image URL fields. -fn media_grid_image_url(item: &serde_json::Value) -> Option { +fn media_grid_image_url( + item: &serde_json::Value, + base_url: &str, +) -> Option { for key in &[ "thumbnail_url", "thumbnail", @@ -1268,12 +1273,22 @@ fn media_grid_image_url(item: &serde_json::Value) -> Option { } } } + // Pinakes media items: construct absolute thumbnail URL from id when + // has_thumbnail is true. Relative paths don't work for in the + // desktop WebView context. + if item.get("has_thumbnail").and_then(|v| v.as_bool()) == Some(true) { + if let Some(id) = item.get("id").and_then(|v| v.as_str()) { + if !id.is_empty() { + return Some(format!("{base_url}/api/v1/media/{id}/thumbnail")); + } + } + } None } /// Probe a JSON object for a human-readable label. fn media_grid_label(item: &serde_json::Value) -> String { - for key in &["title", "name", "label", "caption"] { + for key in &["title", "name", "label", "caption", "file_name"] { if let Some(s) = item.get(*key).and_then(|v| v.as_str()) { if !s.is_empty() { return s.to_string(); @@ -1609,12 +1624,41 @@ fn safe_col_width_css(w: &str) -> Option { None } +/// Convert a `snake_case` JSON key to a human-readable title. +/// `avg_file_size_bytes` -> `Avg File Size Bytes` +fn format_key_name(key: &str) -> String { + key + .split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => { + first.to_uppercase().collect::() + chars.as_str() + }, + } + }) + .collect::>() + .join(" ") +} + #[cfg(test)] mod tests { use pinakes_plugin_api::Expression; use super::*; + #[test] + fn test_format_key_name() { + assert_eq!( + format_key_name("avg_file_size_bytes"), + "Avg File Size Bytes" + ); + assert_eq!(format_key_name("total_media"), "Total Media"); + assert_eq!(format_key_name("id"), "Id"); + assert_eq!(format_key_name(""), ""); + } + #[test] fn test_extract_cell_string() { let row = serde_json::json!({ "name": "Alice", "count": 5 }); 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..6f860c5 --- /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": "file_name", + "header": "Filename" + }, + { + "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": "transform", + "source": "stats", + "expression": "stats.media_by_type" + } + } +} 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..0e8116a --- /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 = "target/wasm32-unknown-unknown/release/media_stats_ui.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +read = [] +write = [] + +[ui] +required_endpoints = ["/api/v1/statistics", "/api/v1/media", "/api/v1/tags"] + +# 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"[]"); +}