WIP editor

Orual 3e94583b e8840cba

+603 -21
+1
Cargo.lock
··· 8670 "humansize", 8671 "jacquard", 8672 "jacquard-axum", 8673 "js-sys", 8674 "markdown-weaver", 8675 "mime-sniffer",
··· 8670 "humansize", 8671 "jacquard", 8672 "jacquard-axum", 8673 + "jacquard-lexicon", 8674 "js-sys", 8675 "markdown-weaver", 8676 "mime-sniffer",
+1
crates/weaver-app/Cargo.toml
··· 23 #dioxus = { version = "0.7.1", features = ["router", "fullstack"] } 24 weaver-common = { path = "../weaver-common" } 25 jacquard = { workspace = true, features = ["streaming"] } 26 jacquard-axum = { workspace = true, optional = true } 27 weaver-api = { path = "../weaver-api", features = ["streaming"] } 28 markdown-weaver = { workspace = true }
··· 23 #dioxus = { version = "0.7.1", features = ["router", "fullstack"] } 24 weaver-common = { path = "../weaver-common" } 25 jacquard = { workspace = true, features = ["streaming"] } 26 + jacquard-lexicon = { workspace = true } 27 jacquard-axum = { workspace = true, optional = true } 28 weaver-api = { path = "../weaver-api", features = ["streaming"] } 29 markdown-weaver = { workspace = true }
+125 -13
crates/weaver-app/assets/styling/record-view.css
··· 35 } 36 37 .metadata-label { 38 - color: var(--color-muted); 39 font-size: 0.85rem; 40 text-transform: uppercase; 41 letter-spacing: 0.1em; ··· 75 border-bottom: 1px solid var(--color-border); 76 margin-bottom: 1.5rem; 77 margin-top: 1.5rem; 78 } 79 80 .tab-button { ··· 83 border: none; 84 padding: 0.5rem 1rem; 85 cursor: pointer; 86 - color: var(--color-muted); 87 text-transform: uppercase; 88 font-size: 0.9rem; 89 letter-spacing: 0.1em; ··· 101 border-bottom-color: var(--color-primary); 102 } 103 104 .tab-content { 105 min-height: 300px; 106 } ··· 118 119 padding-right: 1rem; 120 border-left: 2px solid var(--color-secondary); 121 - border-bottom: 1px dashed var(--color-muted); 122 } 123 124 .field-label { ··· 129 } 130 131 .path-prefix { 132 - color: var(--color-muted); 133 - opacity: 0.7; 134 } 135 136 .path-final { ··· 223 224 .string-type-tag { 225 font-size: 0.7rem; 226 - color: var(--color-muted); 227 text-transform: uppercase; 228 letter-spacing: 0.05em; 229 } ··· 256 257 /* NSID highlighting */ 258 .nsid-dot { 259 - color: var(--color-muted); 260 opacity: 0.6; 261 } 262 ··· 274 275 /* DID highlighting */ 276 .did-scheme { 277 - color: var(--color-muted); 278 opacity: 0.7; 279 } 280 ··· 294 295 /* Handle highlighting */ 296 .handle-dot { 297 - color: var(--color-muted); 298 opacity: 0.6; 299 } 300 ··· 309 310 /* AT URI highlighting */ 311 .aturi-scheme { 312 - color: var(--color-muted); 313 opacity: 0.7; 314 } 315 ··· 318 } 319 320 .aturi-slash { 321 - color: var(--color-muted); 322 opacity: 0.6; 323 } 324 ··· 332 333 /* URI highlighting */ 334 .uri-scheme { 335 - color: var(--color-muted); 336 opacity: 0.7; 337 font-weight: 500; 338 } 339 340 .uri-separator { 341 - color: var(--color-muted); 342 opacity: 0.6; 343 } 344 ··· 349 .uri-path { 350 color: var(--color-secondary); 351 }
··· 35 } 36 37 .metadata-label { 38 + color: var(--color-subtle); 39 font-size: 0.85rem; 40 text-transform: uppercase; 41 letter-spacing: 0.1em; ··· 75 border-bottom: 1px solid var(--color-border); 76 margin-bottom: 1.5rem; 77 margin-top: 1.5rem; 78 + align-items: center; 79 } 80 81 .tab-button { ··· 84 border: none; 85 padding: 0.5rem 1rem; 86 cursor: pointer; 87 + color: var(--color-subtle); 88 text-transform: uppercase; 89 font-size: 0.9rem; 90 letter-spacing: 0.1em; ··· 102 border-bottom-color: var(--color-primary); 103 } 104 105 + .tab-button.edit-button { 106 + margin-left: auto; 107 + } 108 + 109 + .action-buttons-group { 110 + margin-left: auto; 111 + display: flex; 112 + gap: 0; 113 + align-items: center; 114 + } 115 + 116 + .tab-button.action-button-danger { 117 + color: var(--color-error, #ff6b6b); 118 + } 119 + 120 + .tab-button.action-button-danger:hover { 121 + color: var(--color-error, #ff5252); 122 + border-bottom-color: var(--color-error, #ff6b6b); 123 + } 124 + 125 + .dropdown-wrapper { 126 + position: relative; 127 + display: inline-block; 128 + } 129 + 130 + .dropdown-menu { 131 + position: absolute; 132 + top: 100%; 133 + left: 0; 134 + background: var(--color-background); 135 + border: 1px solid var(--color-border); 136 + border-radius: 4px; 137 + margin-top: 0.25rem; 138 + z-index: 100; 139 + min-width: 150px; 140 + } 141 + 142 + .dropdown-menu button { 143 + display: block; 144 + width: 100%; 145 + padding: 0.5rem 1rem; 146 + background: transparent; 147 + border: none; 148 + text-align: left; 149 + cursor: pointer; 150 + color: var(--color-text); 151 + font-family: var(--font-mono); 152 + } 153 + 154 + .dropdown-menu button:hover { 155 + background: var(--color-hover, rgba(255, 255, 255, 0.05)); 156 + } 157 + 158 .tab-content { 159 min-height: 300px; 160 } ··· 172 173 padding-right: 1rem; 174 border-left: 2px solid var(--color-secondary); 175 + border-bottom: 1px dashed var(--color-subtle); 176 } 177 178 .field-label { ··· 183 } 184 185 .path-prefix { 186 + color: var(--color-subtle); 187 } 188 189 .path-final { ··· 276 277 .string-type-tag { 278 font-size: 0.7rem; 279 + color: var(--color-subtle); 280 text-transform: uppercase; 281 letter-spacing: 0.05em; 282 } ··· 309 310 /* NSID highlighting */ 311 .nsid-dot { 312 + color: var(--color-subtle); 313 opacity: 0.6; 314 } 315 ··· 327 328 /* DID highlighting */ 329 .did-scheme { 330 + color: var(--color-subtle); 331 opacity: 0.7; 332 } 333 ··· 347 348 /* Handle highlighting */ 349 .handle-dot { 350 + color: var(--color-subtle); 351 opacity: 0.6; 352 } 353 ··· 362 363 /* AT URI highlighting */ 364 .aturi-scheme { 365 + color: var(--color-subtle); 366 opacity: 0.7; 367 } 368 ··· 371 } 372 373 .aturi-slash { 374 + color: var(--color-subtle); 375 opacity: 0.6; 376 } 377 ··· 385 386 /* URI highlighting */ 387 .uri-scheme { 388 + color: var(--color-subtle); 389 opacity: 0.7; 390 font-weight: 500; 391 } 392 393 .uri-separator { 394 + color: var(--color-subtle); 395 opacity: 0.6; 396 } 397 ··· 402 .uri-path { 403 color: var(--color-secondary); 404 } 405 + 406 + /* JSON Editor */ 407 + .json-editor { 408 + display: flex; 409 + gap: 1.5rem; 410 + } 411 + 412 + .json-textarea { 413 + flex: 1; 414 + font-family: var(--font-mono); 415 + font-size: 0.9rem; 416 + padding: 1rem; 417 + background: var(--color-background-alt, rgba(0, 0, 0, 0.2)); 418 + border: 1px solid var(--color-border); 419 + color: var(--color-text); 420 + resize: vertical; 421 + line-height: 1.5; 422 + } 423 + 424 + .json-textarea:focus { 425 + outline: none; 426 + border-color: var(--color-primary); 427 + } 428 + 429 + .validation-panel { 430 + flex: 0 0 300px; 431 + font-family: var(--font-mono); 432 + font-size: 0.85rem; 433 + padding: 1rem; 434 + background: var(--color-background-alt, rgba(0, 0, 0, 0.2)); 435 + border: 1px solid var(--color-border); 436 + overflow-y: auto; 437 + align-self: flex-start; 438 + } 439 + 440 + .parse-error, 441 + .validation-errors { 442 + color: var(--color-error, #ff6b6b); 443 + margin-top: 0.5rem; 444 + } 445 + 446 + .parse-success, 447 + .validation-success { 448 + color: var(--color-success, #51cf66); 449 + } 450 + 451 + .validation-errors h4 { 452 + font-size: 0.9rem; 453 + font-weight: 600; 454 + margin-bottom: 0.5rem; 455 + color: var(--color-text); 456 + } 457 + 458 + .validation-errors .error { 459 + padding: 0.25rem 0; 460 + border-left: 2px solid var(--color-error, #ff6b6b); 461 + padding-left: 0.5rem; 462 + margin: 0.25rem 0; 463 + }
+2 -2
crates/weaver-app/assets/styling/theme-defaults.css
··· 46 --color-text: #575279; 47 --color-muted: #9893a5; 48 --color-subtle: #797593; 49 - --color-emphasis: #575279; 50 --color-primary: #907aa9; 51 --color-secondary: #56949f; 52 --color-tertiary: #286983; 53 --color-error: #b4637a; 54 --color-warning: #ea9d34; 55 --color-success: #286983; 56 - --color-border: #dfdad9; 57 --color-link: #d7827e; 58 --color-highlight: #cecacd; 59
··· 46 --color-text: #575279; 47 --color-muted: #9893a5; 48 --color-subtle: #797593; 49 + --color-emphasis: #403d52; 50 --color-primary: #907aa9; 51 --color-secondary: #56949f; 52 --color-tertiary: #286983; 53 --color-error: #b4637a; 54 --color-warning: #ea9d34; 55 --color-success: #286983; 56 + --color-border: #908caa; 57 --color-link: #d7827e; 58 --color-highlight: #cecacd; 59
+38
crates/weaver-app/src/fetch.rs
··· 9 use jacquard::identity::resolver::DidDocResponse; 10 use jacquard::identity::resolver::IdentityError; 11 use jacquard::identity::resolver::ResolverOptions; 12 use jacquard::identity::JacquardResolver; 13 use jacquard::oauth::client::OAuthClient; 14 use jacquard::oauth::client::OAuthSession; 15 use jacquard::prelude::*; ··· 253 did: &Did<'_>, 254 ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> { 255 self.oauth_client.client.resolve_did_doc(did) 256 } 257 } 258 ··· 741 did: &Did<'_>, 742 ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> { 743 self.client.resolve_did_doc(did) 744 } 745 } 746
··· 9 use jacquard::identity::resolver::DidDocResponse; 10 use jacquard::identity::resolver::IdentityError; 11 use jacquard::identity::resolver::ResolverOptions; 12 + use jacquard::identity::lexicon_resolver::{LexiconSchemaResolver, ResolvedLexiconSchema, LexiconResolutionError}; 13 use jacquard::identity::JacquardResolver; 14 + use jacquard::types::string::Nsid; 15 use jacquard::oauth::client::OAuthClient; 16 use jacquard::oauth::client::OAuthSession; 17 use jacquard::prelude::*; ··· 255 did: &Did<'_>, 256 ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> { 257 self.oauth_client.client.resolve_did_doc(did) 258 + } 259 + } 260 + 261 + impl LexiconSchemaResolver for Client { 262 + #[cfg(not(target_arch = "wasm32"))] 263 + async fn resolve_lexicon_schema( 264 + &self, 265 + nsid: &Nsid<'_>, 266 + ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> { 267 + self.oauth_client.client.resolve_lexicon_schema(nsid).await 268 + } 269 + 270 + #[cfg(target_arch = "wasm32")] 271 + async fn resolve_lexicon_schema( 272 + &self, 273 + nsid: &Nsid<'_>, 274 + ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> { 275 + self.oauth_client.client.resolve_lexicon_schema(nsid).await 276 } 277 } 278 ··· 761 did: &Did<'_>, 762 ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> { 763 self.client.resolve_did_doc(did) 764 + } 765 + } 766 + 767 + impl LexiconSchemaResolver for CachedFetcher { 768 + #[cfg(not(target_arch = "wasm32"))] 769 + async fn resolve_lexicon_schema( 770 + &self, 771 + nsid: &Nsid<'_>, 772 + ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> { 773 + self.client.resolve_lexicon_schema(nsid).await 774 + } 775 + 776 + #[cfg(target_arch = "wasm32")] 777 + async fn resolve_lexicon_schema( 778 + &self, 779 + nsid: &Nsid<'_>, 780 + ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> { 781 + self.client.resolve_lexicon_schema(nsid).await 782 } 783 } 784
+436 -6
crates/weaver-app/src/views/record.rs
··· 1 use crate::fetch::CachedFetcher; 2 use dioxus::prelude::*; 3 use humansize::format_size; 4 use jacquard::{ 5 client::AgentSessionExt, 6 common::{Data, IntoStatic}, 7 smol_str::SmolStr, 8 - types::aturi::AtUri, 9 }; 10 use weaver_renderer::{code_pretty::highlight_code, css::generate_default_css}; 11 ··· 31 } 32 let uri = use_signal(|| at_uri.unwrap()); 33 let mut view_mode = use_signal(|| ViewMode::Pretty); 34 - let record = use_resource(move || { 35 - let client = fetcher.get_client(); 36 37 async move { client.fetch_record_slingshot(&uri()).await } 38 }); 39 if let Some(Ok(record)) = &*record.read_unchecked() { 40 let record_value = record.value.clone().into_static(); 41 let json = serde_json::to_string_pretty(&record_value).unwrap(); 42 rsx! { 43 document::Stylesheet { href: asset!("/assets/styling/record-view.css") } ··· 74 onclick: move |_| view_mode.set(ViewMode::Json), 75 "JSON" 76 } 77 } 78 div { 79 class: "tab-content", 80 - match view_mode() { 81 - ViewMode::Pretty => rsx! { 82 PrettyRecordView { record: record_value.clone(), uri: uri().clone() } 83 }, 84 - ViewMode::Json => rsx! { 85 CodeView { 86 code: use_signal(|| json.clone()), 87 lang: Some("json".to_string()), 88 } 89 }, 90 } 91 } 92 } ··· 440 class: Signal<String>, 441 code: ReadSignal<String>, 442 lang: Option<String>, 443 } 444 445 /// Render some text as markdown.
··· 1 + use crate::Route; 2 + use crate::auth::AuthState; 3 + use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; 4 use crate::fetch::CachedFetcher; 5 use dioxus::prelude::*; 6 + use dioxus_logger::tracing::*; 7 use humansize::format_size; 8 + use jacquard::prelude::*; 9 + use jacquard::smol_str::ToSmolStr; 10 use jacquard::{ 11 client::AgentSessionExt, 12 common::{Data, IntoStatic}, 13 + identity::lexicon_resolver::LexiconSchemaResolver, 14 smol_str::SmolStr, 15 + types::{aturi::AtUri, ident::AtIdentifier, string::Nsid}, 16 + }; 17 + use weaver_api::com_atproto::repo::{ 18 + create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord, 19 }; 20 use weaver_renderer::{code_pretty::highlight_code, css::generate_default_css}; 21 ··· 41 } 42 let uri = use_signal(|| at_uri.unwrap()); 43 let mut view_mode = use_signal(|| ViewMode::Pretty); 44 + let mut edit_mode = use_signal(|| false); 45 + let navigator = use_navigator(); 46 47 + let client = fetcher.get_client(); 48 + let record = use_resource(move || { 49 + let client = client.clone(); 50 async move { client.fetch_record_slingshot(&uri()).await } 51 }); 52 + 53 + // Check ownership for edit access 54 + let auth_state = use_context::<Signal<AuthState>>(); 55 + let is_owner = use_memo(move || { 56 + let auth = auth_state(); 57 + if !auth.is_authenticated() { 58 + return false; 59 + } 60 + 61 + // authority() returns &AtIdentifier which can be Did or Handle 62 + match uri().authority() { 63 + AtIdentifier::Did(record_did) => auth.did.as_ref() == Some(record_did), 64 + AtIdentifier::Handle(_) => { 65 + // Can't easily check ownership for handles without async resolution 66 + false 67 + } 68 + } 69 + }); 70 if let Some(Ok(record)) = &*record.read_unchecked() { 71 let record_value = record.value.clone().into_static(); 72 + let mut edit_data = use_signal(|| record_value.clone()); 73 + let nsid = use_memo(move || edit_data().type_discriminator().map(|s| s.to_string())); 74 let json = serde_json::to_string_pretty(&record_value).unwrap(); 75 rsx! { 76 document::Stylesheet { href: asset!("/assets/styling/record-view.css") } ··· 107 onclick: move |_| view_mode.set(ViewMode::Json), 108 "JSON" 109 } 110 + if is_owner() && !edit_mode() { 111 + { 112 + let record_value_clone = record_value.clone(); 113 + rsx! { 114 + button { 115 + class: "tab-button edit-button", 116 + onclick: move |_| { 117 + edit_data.set(record_value_clone.clone()); 118 + edit_mode.set(true); 119 + }, 120 + "Edit" 121 + } 122 + } 123 + } 124 + } 125 + if edit_mode() { 126 + { 127 + let record_value_clone = record_value.clone(); 128 + let update_fetcher = fetcher.clone(); 129 + let create_fetcher = fetcher.clone(); 130 + let replace_fetcher = fetcher.clone(); 131 + rsx! { 132 + ActionButtons { 133 + on_update: move |_| { 134 + let fetcher = update_fetcher.clone(); 135 + let uri = uri(); 136 + let data = edit_data(); 137 + spawn(async move { 138 + if let Some((did, _)) = fetcher.session_info().await { 139 + if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) { 140 + let collection = Nsid::new(collection_str.as_str()).ok(); 141 + if let Some(collection) = collection { 142 + let request = PutRecord::new() 143 + .repo(AtIdentifier::Did(did)) 144 + .collection(collection) 145 + .rkey(rkey.clone()) 146 + .record(data) 147 + .build(); 148 + 149 + match fetcher.send(request).await { 150 + Ok(_) => { 151 + dioxus_logger::tracing::info!("Record updated successfully"); 152 + edit_mode.set(false); 153 + } 154 + Err(e) => { 155 + dioxus_logger::tracing::error!("Failed to update record: {:?}", e); 156 + } 157 + } 158 + } 159 + } 160 + } 161 + }); 162 + }, 163 + on_save_new: move |_| { 164 + let fetcher = create_fetcher.clone(); 165 + let data = edit_data(); 166 + let nav = navigator.clone(); 167 + spawn(async move { 168 + if let Some((did, _)) = fetcher.session_info().await { 169 + if let Some(collection_str) = data.type_discriminator() { 170 + let collection = Nsid::new(collection_str).ok(); 171 + if let Some(collection) = collection { 172 + let request = CreateRecord::new() 173 + .repo(AtIdentifier::Did(did)) 174 + .collection(collection) 175 + .record(data.clone()) 176 + .build(); 177 + 178 + match fetcher.send(request).await { 179 + Ok(response) => { 180 + if let Ok(output) = response.into_output() { 181 + dioxus_logger::tracing::info!("Record created: {}", output.uri); 182 + nav.push(Route::RecordView { uri: output.uri.to_smolstr() }); 183 + } 184 + } 185 + Err(e) => { 186 + dioxus_logger::tracing::error!("Failed to create record: {:?}", e); 187 + } 188 + } 189 + } 190 + } 191 + } 192 + }); 193 + }, 194 + on_replace: move |_| { 195 + let fetcher = replace_fetcher.clone(); 196 + let uri = uri(); 197 + let data = edit_data(); 198 + let nav = navigator.clone(); 199 + spawn(async move { 200 + if let Some((did, _)) = fetcher.session_info().await { 201 + if let Some(new_collection_str) = data.type_discriminator() { 202 + let new_collection = Nsid::new(new_collection_str).ok(); 203 + if let Some(new_collection) = new_collection { 204 + // Create new record 205 + let create_req = CreateRecord::new() 206 + .repo(AtIdentifier::Did(did.clone())) 207 + .collection(new_collection) 208 + .record(data.clone()) 209 + .build(); 210 + 211 + match fetcher.send(create_req).await { 212 + Ok(response) => { 213 + if let Ok(create_output) = response.into_output() { 214 + // Delete old record 215 + if let (Some(old_collection_str), Some(old_rkey)) = (uri.collection(), uri.rkey()) { 216 + let old_collection = Nsid::new(old_collection_str.as_str()).ok(); 217 + if let Some(old_collection) = old_collection { 218 + let delete_req = DeleteRecord::new() 219 + .repo(AtIdentifier::Did(did)) 220 + .collection(old_collection) 221 + .rkey(old_rkey.clone()) 222 + .build(); 223 + 224 + if let Err(e) = fetcher.send(delete_req).await { 225 + warn!("Created new record but failed to delete old: {:?}", e); 226 + } 227 + } 228 + } 229 + 230 + info!("Record replaced: {}", create_output.uri); 231 + nav.push(Route::RecordView { uri: create_output.uri.to_smolstr() }); 232 + } 233 + } 234 + Err(e) => { 235 + error!("Failed to replace record: {:?}", e); 236 + } 237 + } 238 + } 239 + } 240 + } 241 + }); 242 + }, 243 + on_delete: move |_| { 244 + let fetcher = fetcher.clone(); 245 + let uri = uri(); 246 + let nav = navigator.clone(); 247 + spawn(async move { 248 + if let Some((did, _)) = fetcher.session_info().await { 249 + if let (Some(collection_str), Some(rkey)) = (uri.collection(), uri.rkey()) { 250 + let collection = Nsid::new(collection_str.as_str()).ok(); 251 + if let Some(collection) = collection { 252 + let request = DeleteRecord::new() 253 + .repo(AtIdentifier::Did(did)) 254 + .collection(collection) 255 + .rkey(rkey.clone()) 256 + .build(); 257 + 258 + match fetcher.send(request).await { 259 + Ok(_) => { 260 + info!("Record deleted"); 261 + nav.push(Route::Home {}); 262 + } 263 + Err(e) => { 264 + error!("Failed to delete record: {:?}", e); 265 + } 266 + } 267 + } 268 + } 269 + } 270 + }); 271 + }, 272 + on_cancel: move |_| { 273 + edit_data.set(record_value_clone.clone()); 274 + edit_mode.set(false); 275 + }, 276 + } 277 + } 278 + } 279 + } 280 } 281 div { 282 class: "tab-content", 283 + match (view_mode(), edit_mode()) { 284 + (ViewMode::Pretty, false) => rsx! { 285 PrettyRecordView { record: record_value.clone(), uri: uri().clone() } 286 }, 287 + (ViewMode::Json, false) => rsx! { 288 CodeView { 289 code: use_signal(|| json.clone()), 290 lang: Some("json".to_string()), 291 } 292 }, 293 + (ViewMode::Pretty, true) => rsx! { 294 + div { "Pretty editor not yet implemented" } 295 + }, 296 + (ViewMode::Json, true) => rsx! { 297 + JsonEditor { 298 + data: edit_data, 299 + nsid: nsid, 300 + } 301 + }, 302 } 303 } 304 } ··· 652 class: Signal<String>, 653 code: ReadSignal<String>, 654 lang: Option<String>, 655 + } 656 + 657 + #[component] 658 + fn JsonEditor(data: Signal<Data<'static>>, nsid: ReadSignal<Option<String>>) -> Element { 659 + let mut json_text = 660 + use_signal(|| serde_json::to_string_pretty(&*data.read()).unwrap_or_default()); 661 + let mut parse_error = use_signal(|| None::<String>); 662 + 663 + let height = use_memo(move || { 664 + let line_count = json_text().lines().count(); 665 + let min_lines = 10; 666 + let lines = line_count.max(min_lines); 667 + // line-height is 1.5, font-size is 0.9rem (approx 14.4px), so each line is ~21.6px 668 + // Add padding (1rem top + 1rem bottom = 2rem = 32px) 669 + format!("{}px", lines * 22 + 32) 670 + }); 671 + 672 + let fetcher = use_context::<CachedFetcher>(); 673 + 674 + let validation = use_resource(move || { 675 + let text = json_text(); 676 + let nsid_val = nsid(); 677 + let fetcher = fetcher.clone(); 678 + 679 + async move { 680 + // Only validate if we have an NSID 681 + let nsid_str = nsid_val?; 682 + 683 + // Parse JSON to Data 684 + let parsed = match serde_json::from_str::<Data>(&text) { 685 + Ok(val) => val.into_static(), 686 + Err(e) => { 687 + return Some((None, Some(e.to_string()))); 688 + } 689 + }; 690 + 691 + // Resolve lexicon if needed 692 + let registry = jacquard_lexicon::schema::SchemaRegistry::from_inventory(); 693 + if registry.get(&nsid_str).is_none() { 694 + let nsid_str = nsid_str.split('#').next(); 695 + if let Some(Ok(nsid_parsed)) = nsid_str.map(|s| Nsid::new(s)) { 696 + if let Ok(schema) = fetcher.resolve_lexicon_schema(&nsid_parsed).await { 697 + registry.insert(nsid_parsed.to_smolstr(), schema.doc); 698 + } 699 + } 700 + } 701 + 702 + // Validate 703 + let validator = jacquard_lexicon::validation::SchemaValidator::from_registry(registry); 704 + let result = validator.validate_by_nsid(&nsid_str, &parsed); 705 + 706 + Some((Some(result), None)) 707 + } 708 + }); 709 + 710 + rsx! { 711 + div { class: "json-editor", 712 + textarea { 713 + class: "json-textarea", 714 + style: "height: {height};", 715 + value: "{json_text}", 716 + oninput: move |evt| { 717 + json_text.set(evt.value()); 718 + // Update data signal on successful parse 719 + if let Ok(parsed) = serde_json::from_str::<Data>(&evt.value()) { 720 + data.set(parsed.into_static()); 721 + } 722 + }, 723 + } 724 + 725 + ValidationPanel { 726 + validation: validation, 727 + } 728 + } 729 + } 730 + } 731 + 732 + #[component] 733 + fn ActionButtons( 734 + on_update: EventHandler<()>, 735 + on_save_new: EventHandler<()>, 736 + on_replace: EventHandler<()>, 737 + on_delete: EventHandler<()>, 738 + on_cancel: EventHandler<()>, 739 + ) -> Element { 740 + let mut show_save_dropdown = use_signal(|| false); 741 + let mut show_replace_warning = use_signal(|| false); 742 + let mut show_delete_confirm = use_signal(|| false); 743 + 744 + rsx! { 745 + div { class: "action-buttons-group", 746 + button { 747 + class: "tab-button action-button", 748 + onclick: move |_| on_update.call(()), 749 + "Update" 750 + } 751 + 752 + div { class: "dropdown-wrapper", 753 + button { 754 + class: "tab-button action-button", 755 + onclick: move |_| show_save_dropdown.toggle(), 756 + "Save as New ▼" 757 + } 758 + if show_save_dropdown() { 759 + div { class: "dropdown-menu", 760 + button { 761 + onclick: move |_| { 762 + show_save_dropdown.set(false); 763 + on_save_new.call(()); 764 + }, 765 + "Save as New" 766 + } 767 + button { 768 + onclick: move |_| { 769 + show_save_dropdown.set(false); 770 + show_replace_warning.set(true); 771 + }, 772 + "Replace" 773 + } 774 + } 775 + } 776 + } 777 + 778 + if show_replace_warning() { 779 + div { class: "inline-warning", 780 + "⚠️ This will delete the current record and create a new one with a different rkey. " 781 + button { 782 + onclick: move |_| { 783 + show_replace_warning.set(false); 784 + on_replace.call(()); 785 + }, 786 + "Yes" 787 + } 788 + button { 789 + onclick: move |_| show_replace_warning.set(false), 790 + "No" 791 + } 792 + } 793 + } 794 + 795 + button { 796 + class: "tab-button action-button action-button-danger", 797 + onclick: move |_| show_delete_confirm.set(true), 798 + "Delete" 799 + } 800 + 801 + DialogRoot { 802 + open: Some(show_delete_confirm()), 803 + on_open_change: move |open: bool| { 804 + show_delete_confirm.set(open); 805 + }, 806 + DialogContent { 807 + DialogTitle { "Delete Record?" } 808 + DialogDescription { 809 + "This action cannot be undone." 810 + } 811 + div { class: "dialog-actions", 812 + button { 813 + onclick: move |_| { 814 + show_delete_confirm.set(false); 815 + on_delete.call(()); 816 + }, 817 + "Delete" 818 + } 819 + button { 820 + onclick: move |_| show_delete_confirm.set(false), 821 + "Cancel" 822 + } 823 + } 824 + } 825 + } 826 + 827 + button { 828 + class: "tab-button action-button", 829 + onclick: move |_| on_cancel.call(()), 830 + "Cancel" 831 + } 832 + } 833 + } 834 + } 835 + 836 + #[component] 837 + fn ValidationPanel( 838 + validation: Resource< 839 + Option<( 840 + Option<jacquard_lexicon::validation::ValidationResult>, 841 + Option<String>, 842 + )>, 843 + >, 844 + ) -> Element { 845 + rsx! { 846 + div { class: "validation-panel", 847 + if let Some(Some((result_opt, parse_error_opt))) = validation.read().as_ref() { 848 + if let Some(parse_err) = parse_error_opt { 849 + div { class: "parse-error", 850 + "❌ Invalid JSON: {parse_err}" 851 + } 852 + } else { 853 + div { class: "parse-success", "✓ Valid JSON syntax" } 854 + } 855 + 856 + if let Some(result) = result_opt { 857 + if result.is_valid() { 858 + div { class: "validation-success", "✓ Record is valid" } 859 + } else { 860 + div { class: "validation-errors", 861 + h4 { "Validation Errors:" } 862 + for error in result.all_errors() { 863 + div { class: "error", "❌ {error}" } 864 + } 865 + } 866 + } 867 + } 868 + } else { 869 + div { "Validating..." } 870 + } 871 + } 872 + } 873 } 874 875 /// Render some text as markdown.