From 3e94583b449b4bd2e23f1f397f8b3a7c107ed222 Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 11 Nov 2025 20:07:34 -0500 Subject: [PATCH] WIP editor --- Cargo.lock | 1 + crates/weaver-app/Cargo.toml | 1 + .../weaver-app/assets/styling/record-view.css | 138 +++++- .../assets/styling/theme-defaults.css | 4 +- crates/weaver-app/src/fetch.rs | 38 ++ crates/weaver-app/src/views/record.rs | 442 +++++++++++++++++- 6 files changed, 603 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4db52eb..2107cb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8670,6 +8670,7 @@ dependencies = [ "humansize", "jacquard", "jacquard-axum", + "jacquard-lexicon", "js-sys", "markdown-weaver", "mime-sniffer", diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 67c11f1..81138b6 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -23,6 +23,7 @@ dioxus = { version = "0.7.1", features = ["router"] } #dioxus = { version = "0.7.1", features = ["router", "fullstack"] } weaver-common = { path = "../weaver-common" } jacquard = { workspace = true, features = ["streaming"] } +jacquard-lexicon = { workspace = true } jacquard-axum = { workspace = true, optional = true } weaver-api = { path = "../weaver-api", features = ["streaming"] } markdown-weaver = { workspace = true } diff --git a/crates/weaver-app/assets/styling/record-view.css b/crates/weaver-app/assets/styling/record-view.css index 86637f0..3a97975 100644 --- a/crates/weaver-app/assets/styling/record-view.css +++ b/crates/weaver-app/assets/styling/record-view.css @@ -35,7 +35,7 @@ } .metadata-label { - color: var(--color-muted); + color: var(--color-subtle); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.1em; @@ -75,6 +75,7 @@ border-bottom: 1px solid var(--color-border); margin-bottom: 1.5rem; margin-top: 1.5rem; + align-items: center; } .tab-button { @@ -83,7 +84,7 @@ border: none; padding: 0.5rem 1rem; cursor: pointer; - color: var(--color-muted); + color: var(--color-subtle); text-transform: uppercase; font-size: 0.9rem; letter-spacing: 0.1em; @@ -101,6 +102,59 @@ border-bottom-color: var(--color-primary); } +.tab-button.edit-button { + margin-left: auto; +} + +.action-buttons-group { + margin-left: auto; + display: flex; + gap: 0; + align-items: center; +} + +.tab-button.action-button-danger { + color: var(--color-error, #ff6b6b); +} + +.tab-button.action-button-danger:hover { + color: var(--color-error, #ff5252); + border-bottom-color: var(--color-error, #ff6b6b); +} + +.dropdown-wrapper { + position: relative; + display: inline-block; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 4px; + margin-top: 0.25rem; + z-index: 100; + min-width: 150px; +} + +.dropdown-menu button { + display: block; + width: 100%; + padding: 0.5rem 1rem; + background: transparent; + border: none; + text-align: left; + cursor: pointer; + color: var(--color-text); + font-family: var(--font-mono); +} + +.dropdown-menu button:hover { + background: var(--color-hover, rgba(255, 255, 255, 0.05)); +} + .tab-content { min-height: 300px; } @@ -118,7 +172,7 @@ padding-right: 1rem; border-left: 2px solid var(--color-secondary); - border-bottom: 1px dashed var(--color-muted); + border-bottom: 1px dashed var(--color-subtle); } .field-label { @@ -129,8 +183,7 @@ } .path-prefix { - color: var(--color-muted); - opacity: 0.7; + color: var(--color-subtle); } .path-final { @@ -223,7 +276,7 @@ .string-type-tag { font-size: 0.7rem; - color: var(--color-muted); + color: var(--color-subtle); text-transform: uppercase; letter-spacing: 0.05em; } @@ -256,7 +309,7 @@ /* NSID highlighting */ .nsid-dot { - color: var(--color-muted); + color: var(--color-subtle); opacity: 0.6; } @@ -274,7 +327,7 @@ /* DID highlighting */ .did-scheme { - color: var(--color-muted); + color: var(--color-subtle); opacity: 0.7; } @@ -294,7 +347,7 @@ /* Handle highlighting */ .handle-dot { - color: var(--color-muted); + color: var(--color-subtle); opacity: 0.6; } @@ -309,7 +362,7 @@ /* AT URI highlighting */ .aturi-scheme { - color: var(--color-muted); + color: var(--color-subtle); opacity: 0.7; } @@ -318,7 +371,7 @@ } .aturi-slash { - color: var(--color-muted); + color: var(--color-subtle); opacity: 0.6; } @@ -332,13 +385,13 @@ /* URI highlighting */ .uri-scheme { - color: var(--color-muted); + color: var(--color-subtle); opacity: 0.7; font-weight: 500; } .uri-separator { - color: var(--color-muted); + color: var(--color-subtle); opacity: 0.6; } @@ -349,3 +402,62 @@ .uri-path { color: var(--color-secondary); } + +/* JSON Editor */ +.json-editor { + display: flex; + gap: 1.5rem; +} + +.json-textarea { + flex: 1; + font-family: var(--font-mono); + font-size: 0.9rem; + padding: 1rem; + background: var(--color-background-alt, rgba(0, 0, 0, 0.2)); + border: 1px solid var(--color-border); + color: var(--color-text); + resize: vertical; + line-height: 1.5; +} + +.json-textarea:focus { + outline: none; + border-color: var(--color-primary); +} + +.validation-panel { + flex: 0 0 300px; + font-family: var(--font-mono); + font-size: 0.85rem; + padding: 1rem; + background: var(--color-background-alt, rgba(0, 0, 0, 0.2)); + border: 1px solid var(--color-border); + overflow-y: auto; + align-self: flex-start; +} + +.parse-error, +.validation-errors { + color: var(--color-error, #ff6b6b); + margin-top: 0.5rem; +} + +.parse-success, +.validation-success { + color: var(--color-success, #51cf66); +} + +.validation-errors h4 { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--color-text); +} + +.validation-errors .error { + padding: 0.25rem 0; + border-left: 2px solid var(--color-error, #ff6b6b); + padding-left: 0.5rem; + margin: 0.25rem 0; +} diff --git a/crates/weaver-app/assets/styling/theme-defaults.css b/crates/weaver-app/assets/styling/theme-defaults.css index 9d9a668..a640ed1 100644 --- a/crates/weaver-app/assets/styling/theme-defaults.css +++ b/crates/weaver-app/assets/styling/theme-defaults.css @@ -46,14 +46,14 @@ --color-text: #575279; --color-muted: #9893a5; --color-subtle: #797593; - --color-emphasis: #575279; + --color-emphasis: #403d52; --color-primary: #907aa9; --color-secondary: #56949f; --color-tertiary: #286983; --color-error: #b4637a; --color-warning: #ea9d34; --color-success: #286983; - --color-border: #dfdad9; + --color-border: #908caa; --color-link: #d7827e; --color-highlight: #cecacd; diff --git a/crates/weaver-app/src/fetch.rs b/crates/weaver-app/src/fetch.rs index 1787d81..980157d 100644 --- a/crates/weaver-app/src/fetch.rs +++ b/crates/weaver-app/src/fetch.rs @@ -9,7 +9,9 @@ use jacquard::error::XrpcResult; use jacquard::identity::resolver::DidDocResponse; use jacquard::identity::resolver::IdentityError; use jacquard::identity::resolver::ResolverOptions; +use jacquard::identity::lexicon_resolver::{LexiconSchemaResolver, ResolvedLexiconSchema, LexiconResolutionError}; use jacquard::identity::JacquardResolver; +use jacquard::types::string::Nsid; use jacquard::oauth::client::OAuthClient; use jacquard::oauth::client::OAuthSession; use jacquard::prelude::*; @@ -256,6 +258,24 @@ impl IdentityResolver for Client { } } +impl LexiconSchemaResolver for Client { + #[cfg(not(target_arch = "wasm32"))] + async fn resolve_lexicon_schema( + &self, + nsid: &Nsid<'_>, + ) -> std::result::Result, LexiconResolutionError> { + self.oauth_client.client.resolve_lexicon_schema(nsid).await + } + + #[cfg(target_arch = "wasm32")] + async fn resolve_lexicon_schema( + &self, + nsid: &Nsid<'_>, + ) -> std::result::Result, LexiconResolutionError> { + self.oauth_client.client.resolve_lexicon_schema(nsid).await + } +} + impl AgentSession for Client { #[doc = " Identify the kind of session."] fn session_kind(&self) -> AgentKind { @@ -744,6 +764,24 @@ impl IdentityResolver for CachedFetcher { } } +impl LexiconSchemaResolver for CachedFetcher { + #[cfg(not(target_arch = "wasm32"))] + async fn resolve_lexicon_schema( + &self, + nsid: &Nsid<'_>, + ) -> std::result::Result, LexiconResolutionError> { + self.client.resolve_lexicon_schema(nsid).await + } + + #[cfg(target_arch = "wasm32")] + async fn resolve_lexicon_schema( + &self, + nsid: &Nsid<'_>, + ) -> std::result::Result, LexiconResolutionError> { + self.client.resolve_lexicon_schema(nsid).await + } +} + impl AgentSession for CachedFetcher { #[doc = " Identify the kind of session."] fn session_kind(&self) -> AgentKind { diff --git a/crates/weaver-app/src/views/record.rs b/crates/weaver-app/src/views/record.rs index 7c80e93..e4faf1b 100644 --- a/crates/weaver-app/src/views/record.rs +++ b/crates/weaver-app/src/views/record.rs @@ -1,11 +1,21 @@ +use crate::Route; +use crate::auth::AuthState; +use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; use crate::fetch::CachedFetcher; use dioxus::prelude::*; +use dioxus_logger::tracing::*; use humansize::format_size; +use jacquard::prelude::*; +use jacquard::smol_str::ToSmolStr; use jacquard::{ client::AgentSessionExt, common::{Data, IntoStatic}, + identity::lexicon_resolver::LexiconSchemaResolver, smol_str::SmolStr, - types::aturi::AtUri, + types::{aturi::AtUri, ident::AtIdentifier, string::Nsid}, +}; +use weaver_api::com_atproto::repo::{ + create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord, }; use weaver_renderer::{code_pretty::highlight_code, css::generate_default_css}; @@ -31,13 +41,36 @@ pub fn RecordView(uri: ReadSignal) -> Element { } let uri = use_signal(|| at_uri.unwrap()); let mut view_mode = use_signal(|| ViewMode::Pretty); - let record = use_resource(move || { - let client = fetcher.get_client(); + let mut edit_mode = use_signal(|| false); + let navigator = use_navigator(); + let client = fetcher.get_client(); + let record = use_resource(move || { + let client = client.clone(); async move { client.fetch_record_slingshot(&uri()).await } }); + + // Check ownership for edit access + let auth_state = use_context::>(); + let is_owner = use_memo(move || { + let auth = auth_state(); + if !auth.is_authenticated() { + return false; + } + + // authority() returns &AtIdentifier which can be Did or Handle + match uri().authority() { + AtIdentifier::Did(record_did) => auth.did.as_ref() == Some(record_did), + AtIdentifier::Handle(_) => { + // Can't easily check ownership for handles without async resolution + false + } + } + }); if let Some(Ok(record)) = &*record.read_unchecked() { let record_value = record.value.clone().into_static(); + let mut edit_data = use_signal(|| record_value.clone()); + let nsid = use_memo(move || edit_data().type_discriminator().map(|s| s.to_string())); let json = serde_json::to_string_pretty(&record_value).unwrap(); rsx! { document::Stylesheet { href: asset!("/assets/styling/record-view.css") } @@ -74,19 +107,198 @@ pub fn RecordView(uri: ReadSignal) -> Element { onclick: move |_| view_mode.set(ViewMode::Json), "JSON" } + if is_owner() && !edit_mode() { + { + let record_value_clone = record_value.clone(); + rsx! { + button { + class: "tab-button edit-button", + onclick: move |_| { + edit_data.set(record_value_clone.clone()); + edit_mode.set(true); + }, + "Edit" + } + } + } + } + if edit_mode() { + { + let record_value_clone = record_value.clone(); + let update_fetcher = fetcher.clone(); + let create_fetcher = fetcher.clone(); + let replace_fetcher = fetcher.clone(); + rsx! { + ActionButtons { + on_update: move |_| { + let fetcher = update_fetcher.clone(); + let uri = uri(); + let data = edit_data(); + spawn(async move { + if let Some((did, _)) = fetcher.session_info().await { + if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) { + let collection = Nsid::new(collection_str.as_str()).ok(); + if let Some(collection) = collection { + let request = PutRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(collection) + .rkey(rkey.clone()) + .record(data) + .build(); + + match fetcher.send(request).await { + Ok(_) => { + dioxus_logger::tracing::info!("Record updated successfully"); + edit_mode.set(false); + } + Err(e) => { + dioxus_logger::tracing::error!("Failed to update record: {:?}", e); + } + } + } + } + } + }); + }, + on_save_new: move |_| { + let fetcher = create_fetcher.clone(); + let data = edit_data(); + let nav = navigator.clone(); + spawn(async move { + if let Some((did, _)) = fetcher.session_info().await { + if let Some(collection_str) = data.type_discriminator() { + let collection = Nsid::new(collection_str).ok(); + if let Some(collection) = collection { + let request = CreateRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(collection) + .record(data.clone()) + .build(); + + match fetcher.send(request).await { + Ok(response) => { + if let Ok(output) = response.into_output() { + dioxus_logger::tracing::info!("Record created: {}", output.uri); + nav.push(Route::RecordView { uri: output.uri.to_smolstr() }); + } + } + Err(e) => { + dioxus_logger::tracing::error!("Failed to create record: {:?}", e); + } + } + } + } + } + }); + }, + on_replace: move |_| { + let fetcher = replace_fetcher.clone(); + let uri = uri(); + let data = edit_data(); + let nav = navigator.clone(); + spawn(async move { + if let Some((did, _)) = fetcher.session_info().await { + if let Some(new_collection_str) = data.type_discriminator() { + let new_collection = Nsid::new(new_collection_str).ok(); + if let Some(new_collection) = new_collection { + // Create new record + let create_req = CreateRecord::new() + .repo(AtIdentifier::Did(did.clone())) + .collection(new_collection) + .record(data.clone()) + .build(); + + match fetcher.send(create_req).await { + Ok(response) => { + if let Ok(create_output) = response.into_output() { + // Delete old record + if let (Some(old_collection_str), Some(old_rkey)) = (uri.collection(), uri.rkey()) { + let old_collection = Nsid::new(old_collection_str.as_str()).ok(); + if let Some(old_collection) = old_collection { + let delete_req = DeleteRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(old_collection) + .rkey(old_rkey.clone()) + .build(); + + if let Err(e) = fetcher.send(delete_req).await { + warn!("Created new record but failed to delete old: {:?}", e); + } + } + } + + info!("Record replaced: {}", create_output.uri); + nav.push(Route::RecordView { uri: create_output.uri.to_smolstr() }); + } + } + Err(e) => { + error!("Failed to replace record: {:?}", e); + } + } + } + } + } + }); + }, + on_delete: move |_| { + let fetcher = fetcher.clone(); + let uri = uri(); + let nav = navigator.clone(); + spawn(async move { + if let Some((did, _)) = fetcher.session_info().await { + if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) { + let collection = Nsid::new(collection_str.as_str()).ok(); + if let Some(collection) = collection { + let request = DeleteRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(collection) + .rkey(rkey.clone()) + .build(); + + match fetcher.send(request).await { + Ok(_) => { + info!("Record deleted"); + nav.push(Route::Home {}); + } + Err(e) => { + error!("Failed to delete record: {:?}", e); + } + } + } + } + } + }); + }, + on_cancel: move |_| { + edit_data.set(record_value_clone.clone()); + edit_mode.set(false); + }, + } + } + } + } } div { class: "tab-content", - match view_mode() { - ViewMode::Pretty => rsx! { + match (view_mode(), edit_mode()) { + (ViewMode::Pretty, false) => rsx! { PrettyRecordView { record: record_value.clone(), uri: uri().clone() } }, - ViewMode::Json => rsx! { + (ViewMode::Json, false) => rsx! { CodeView { code: use_signal(|| json.clone()), lang: Some("json".to_string()), } }, + (ViewMode::Pretty, true) => rsx! { + div { "Pretty editor not yet implemented" } + }, + (ViewMode::Json, true) => rsx! { + JsonEditor { + data: edit_data, + nsid: nsid, + } + }, } } } @@ -442,6 +654,224 @@ pub struct CodeViewProps { lang: Option, } +#[component] +fn JsonEditor(data: Signal>, nsid: ReadSignal>) -> Element { + let mut json_text = + use_signal(|| serde_json::to_string_pretty(&*data.read()).unwrap_or_default()); + let mut parse_error = use_signal(|| None::); + + let height = use_memo(move || { + let line_count = json_text().lines().count(); + let min_lines = 10; + let lines = line_count.max(min_lines); + // line-height is 1.5, font-size is 0.9rem (approx 14.4px), so each line is ~21.6px + // Add padding (1rem top + 1rem bottom = 2rem = 32px) + format!("{}px", lines * 22 + 32) + }); + + let fetcher = use_context::(); + + let validation = use_resource(move || { + let text = json_text(); + let nsid_val = nsid(); + let fetcher = fetcher.clone(); + + async move { + // Only validate if we have an NSID + let nsid_str = nsid_val?; + + // Parse JSON to Data + let parsed = match serde_json::from_str::(&text) { + Ok(val) => val.into_static(), + Err(e) => { + return Some((None, Some(e.to_string()))); + } + }; + + // Resolve lexicon if needed + let registry = jacquard_lexicon::schema::SchemaRegistry::from_inventory(); + if registry.get(&nsid_str).is_none() { + let nsid_str = nsid_str.split('#').next(); + if let Some(Ok(nsid_parsed)) = nsid_str.map(|s| Nsid::new(s)) { + if let Ok(schema) = fetcher.resolve_lexicon_schema(&nsid_parsed).await { + registry.insert(nsid_parsed.to_smolstr(), schema.doc); + } + } + } + + // Validate + let validator = jacquard_lexicon::validation::SchemaValidator::from_registry(registry); + let result = validator.validate_by_nsid(&nsid_str, &parsed); + + Some((Some(result), None)) + } + }); + + rsx! { + div { class: "json-editor", + textarea { + class: "json-textarea", + style: "height: {height};", + value: "{json_text}", + oninput: move |evt| { + json_text.set(evt.value()); + // Update data signal on successful parse + if let Ok(parsed) = serde_json::from_str::(&evt.value()) { + data.set(parsed.into_static()); + } + }, + } + + ValidationPanel { + validation: validation, + } + } + } +} + +#[component] +fn ActionButtons( + on_update: EventHandler<()>, + on_save_new: EventHandler<()>, + on_replace: EventHandler<()>, + on_delete: EventHandler<()>, + on_cancel: EventHandler<()>, +) -> Element { + let mut show_save_dropdown = use_signal(|| false); + let mut show_replace_warning = use_signal(|| false); + let mut show_delete_confirm = use_signal(|| false); + + rsx! { + div { class: "action-buttons-group", + button { + class: "tab-button action-button", + onclick: move |_| on_update.call(()), + "Update" + } + + div { class: "dropdown-wrapper", + button { + class: "tab-button action-button", + onclick: move |_| show_save_dropdown.toggle(), + "Save as New ▼" + } + if show_save_dropdown() { + div { class: "dropdown-menu", + button { + onclick: move |_| { + show_save_dropdown.set(false); + on_save_new.call(()); + }, + "Save as New" + } + button { + onclick: move |_| { + show_save_dropdown.set(false); + show_replace_warning.set(true); + }, + "Replace" + } + } + } + } + + if show_replace_warning() { + div { class: "inline-warning", + "⚠️ This will delete the current record and create a new one with a different rkey. " + button { + onclick: move |_| { + show_replace_warning.set(false); + on_replace.call(()); + }, + "Yes" + } + button { + onclick: move |_| show_replace_warning.set(false), + "No" + } + } + } + + button { + class: "tab-button action-button action-button-danger", + onclick: move |_| show_delete_confirm.set(true), + "Delete" + } + + DialogRoot { + open: Some(show_delete_confirm()), + on_open_change: move |open: bool| { + show_delete_confirm.set(open); + }, + DialogContent { + DialogTitle { "Delete Record?" } + DialogDescription { + "This action cannot be undone." + } + div { class: "dialog-actions", + button { + onclick: move |_| { + show_delete_confirm.set(false); + on_delete.call(()); + }, + "Delete" + } + button { + onclick: move |_| show_delete_confirm.set(false), + "Cancel" + } + } + } + } + + button { + class: "tab-button action-button", + onclick: move |_| on_cancel.call(()), + "Cancel" + } + } + } +} + +#[component] +fn ValidationPanel( + validation: Resource< + Option<( + Option, + Option, + )>, + >, +) -> Element { + rsx! { + div { class: "validation-panel", + if let Some(Some((result_opt, parse_error_opt))) = validation.read().as_ref() { + if let Some(parse_err) = parse_error_opt { + div { class: "parse-error", + "❌ Invalid JSON: {parse_err}" + } + } else { + div { class: "parse-success", "✓ Valid JSON syntax" } + } + + if let Some(result) = result_opt { + if result.is_valid() { + div { class: "validation-success", "✓ Record is valid" } + } else { + div { class: "validation-errors", + h4 { "Validation Errors:" } + for error in result.all_errors() { + div { class: "error", "❌ {error}" } + } + } + } + } + } else { + div { "Validating..." } + } + } + } +} + /// Render some text as markdown. #[component] pub fn CodeView(props: CodeViewProps) -> Element { -- knotmirror 0.0.1