at main 777 lines 29 kB view raw
1//! Entry publishing and loading functionality for the markdown editor. 2//! 3//! Handles creating/updating/loading AT Protocol notebook entries. 4 5use dioxus::prelude::*; 6use jacquard::cowstr::ToCowStr; 7use jacquard::smol_str::ToSmolStr; 8use jacquard::types::collection::Collection; 9use jacquard::types::ident::AtIdentifier; 10use jacquard::types::recordkey::RecordKey; 11#[allow(unused_imports)] 12use jacquard::types::string::{AtUri, Datetime, Nsid, Rkey}; 13use jacquard::types::tid::Ticker; 14use jacquard::{IntoStatic, from_data, prelude::*, to_data}; 15use regex_lite::Regex; 16use std::sync::LazyLock; 17use weaver_api::com_atproto::repo::get_record::GetRecord; 18use weaver_api::com_atproto::repo::strong_ref::StrongRef; 19use weaver_api::com_atproto::repo::{create_record::CreateRecord, put_record::PutRecord}; 20use weaver_api::sh_weaver::embed::images::Images; 21use weaver_api::sh_weaver::embed::records::{RecordEmbed, Records}; 22use weaver_api::sh_weaver::notebook::book::Book; 23use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds}; 24use weaver_common::{slugify, WeaverError, WeaverExt}; 25 26use crate::components::notebook::{create_document_for_entry, publication_uri_for_notebook}; 27 28const ENTRY_NSID: &str = "sh.weaver.notebook.entry"; 29 30/// Regex to match draft image paths: /image/{did}/draft/{blob_rkey}/{name} 31/// Captures: 1=did, 2=blob_rkey, 3=name 32static DRAFT_IMAGE_PATH_REGEX: LazyLock<Regex> = 33 LazyLock::new(|| Regex::new(r"/image/([^/]+)/draft/([^/]+)/([^)\s]+)").unwrap()); 34 35/// Rewrite draft image paths to published paths. 36/// 37/// Converts `/image/{did}/draft/{blob_rkey}/{name}` to `/image/{did}/{entry_rkey}/{name}` 38fn rewrite_draft_paths(content: &str, entry_rkey: &str) -> String { 39 DRAFT_IMAGE_PATH_REGEX 40 .replace_all(content, |caps: &regex_lite::Captures| { 41 let did = &caps[1]; 42 let name = &caps[3]; 43 format!("/image/{}/{}/{}", did, entry_rkey, name) 44 }) 45 .into_owned() 46} 47 48/// Rewrite draft paths for notebook entries. 49/// 50/// Converts `/image/{did}/draft/{blob_rkey}/{name}` to `/image/{notebook}/{name}` 51fn rewrite_draft_paths_for_notebook(content: &str, notebook_key: &str) -> String { 52 DRAFT_IMAGE_PATH_REGEX 53 .replace_all(content, |caps: &regex_lite::Captures| { 54 let name = &caps[3]; 55 format!("/image/{}/{}", notebook_key, name) 56 }) 57 .into_owned() 58} 59 60use crate::auth::AuthState; 61use crate::components::editor::SignalEditorDocument; 62use crate::fetch::Fetcher; 63 64use super::storage::{delete_draft, save_to_storage}; 65 66/// Result of a publish operation. 67#[derive(Clone, Debug)] 68pub enum PublishResult { 69 /// Entry was created (new) 70 Created(AtUri<'static>), 71 /// Entry was updated (existing) 72 Updated(AtUri<'static>), 73} 74 75impl PublishResult { 76 pub fn uri(&self) -> &AtUri<'static> { 77 match self { 78 PublishResult::Created(uri) | PublishResult::Updated(uri) => uri, 79 } 80 } 81} 82 83/// Result of fetching an entry for editing. 84#[derive(Clone, PartialEq)] 85pub struct LoadedEntry { 86 pub entry: Entry<'static>, 87 pub entry_ref: StrongRef<'static>, 88} 89 90/// Fetch an existing entry from the PDS for editing. 91pub async fn load_entry_for_editing( 92 fetcher: &Fetcher, 93 uri: &AtUri<'_>, 94) -> Result<LoadedEntry, WeaverError> { 95 // Parse the AT-URI components 96 let ident = uri.authority(); 97 let rkey = uri 98 .rkey() 99 .ok_or_else(|| WeaverError::InvalidNotebook("Entry URI missing rkey".into()))?; 100 101 // Resolve DID and PDS 102 let (did, pds_url) = match ident { 103 AtIdentifier::Did(d) => { 104 let pds = fetcher.client.pds_for_did(d).await.map_err(|e| { 105 WeaverError::InvalidNotebook(format!("Failed to resolve DID: {}", e)) 106 })?; 107 (d.clone(), pds) 108 } 109 AtIdentifier::Handle(h) => { 110 let (did, pds) = fetcher.client.pds_for_handle(h).await.map_err(|e| { 111 WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)) 112 })?; 113 (did, pds) 114 } 115 }; 116 117 // Fetch the entry record 118 let request = GetRecord::new() 119 .repo(AtIdentifier::Did(did)) 120 .collection(Nsid::raw(<Entry as Collection>::NSID)) 121 .rkey(rkey.clone()) 122 .build(); 123 124 let response = fetcher 125 .client 126 .xrpc(pds_url) 127 .send(&request) 128 .await 129 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to fetch entry: {}", e)))?; 130 131 let record = response 132 .into_output() 133 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to parse response: {}", e)))?; 134 135 // Deserialize the entry 136 let entry: Entry = from_data(&record.value) 137 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to deserialize entry: {}", e)))?; 138 139 // Build StrongRef from URI and CID 140 let entry_ref = StrongRef::new() 141 .uri(uri.clone().into_static()) 142 .cid( 143 record 144 .cid 145 .ok_or_else(|| WeaverError::InvalidNotebook("Entry response missing CID".into()))? 146 .into_static(), 147 ) 148 .build(); 149 150 Ok(LoadedEntry { 151 entry: entry.into_static(), 152 entry_ref, 153 }) 154} 155 156/// Publish an entry to the AT Protocol. 157/// 158/// Supports three modes: 159/// - With notebook_title: uses `upsert_entry` to publish to a notebook 160/// - Without notebook but with entry_uri in doc: uses `put_record` to update existing 161/// - Without notebook and no entry_uri: uses `create_record` for free-floating entry 162/// 163/// Draft image paths are rewritten to published paths before publishing. 164/// On successful create, sets `doc.entry_uri` so subsequent publishes update the same record. 165pub async fn publish_entry( 166 fetcher: &Fetcher, 167 doc: &mut SignalEditorDocument, 168 notebook_title: Option<&str>, 169 draft_key: &str, 170) -> Result<PublishResult, WeaverError> { 171 // Get images from the document 172 let editor_images = doc.images(); 173 174 // Resolve AT embed URIs to StrongRefs 175 let at_embed_uris = doc.at_embed_uris(); 176 let mut record_embeds: Vec<RecordEmbed<'static>> = Vec::new(); 177 for uri in at_embed_uris { 178 match fetcher.confirm_record_ref(&uri).await { 179 Ok(strong_ref) => { 180 // Store original URI in name field for lookup when authority differs (handle vs DID) 181 record_embeds.push( 182 RecordEmbed::new() 183 .name(uri.to_cowstr().into_static()) 184 .record(strong_ref) 185 .build(), 186 ); 187 } 188 Err(e) => { 189 tracing::warn!("Failed to resolve embed {}: {}", uri, e); 190 } 191 } 192 } 193 194 // Build embeds if we have images or records 195 tracing::debug!( 196 "[publish_entry] Building embeds: {} images, {} record embeds", 197 editor_images.len(), 198 record_embeds.len() 199 ); 200 let entry_embeds = if editor_images.is_empty() && record_embeds.is_empty() { 201 None 202 } else { 203 let images = if editor_images.is_empty() { 204 None 205 } else { 206 Some(Images { 207 images: editor_images.iter().map(|ei| ei.image.clone()).collect(), 208 extra_data: None, 209 }) 210 }; 211 212 let records = if record_embeds.is_empty() { 213 None 214 } else { 215 Some(Records::new().records(record_embeds).build()) 216 }; 217 218 Some(EntryEmbeds { 219 images, 220 records, 221 ..Default::default() 222 }) 223 }; 224 225 // Build tags (convert Vec<String> to the expected type) 226 let tags = { 227 let tag_strings = doc.tags(); 228 if tag_strings.is_empty() { 229 None 230 } else { 231 Some(tag_strings.into_iter().map(Into::into).collect()) 232 } 233 }; 234 235 // Determine path - use doc path if set, otherwise slugify title 236 let path = { 237 let doc_path = doc.path(); 238 if doc_path.is_empty() { 239 slugify(&doc.title()) 240 } else { 241 doc_path 242 } 243 }; 244 245 let client = fetcher.get_client(); 246 let result = if let Some(notebook) = notebook_title { 247 // Publish to a notebook via upsert_entry 248 // Rewrite draft image paths to notebook paths: /image/{notebook}/{name} 249 let content = rewrite_draft_paths_for_notebook(&doc.content(), notebook); 250 251 let entry = Entry::new() 252 .content(content) 253 .title(doc.title()) 254 .path(path) 255 .created_at(Datetime::now()) 256 .updated_at(Datetime::now()) 257 .maybe_tags(tags) 258 .maybe_embeds(entry_embeds) 259 .build(); 260 261 // Check if we have a stored notebook URI (for re-publishing to same notebook) 262 // This avoids duplicate notebook creation when re-publishing 263 let (notebook_uri, entry_refs) = if let Some(stored_uri) = doc.notebook_uri() { 264 // Try to fetch notebook directly by URI to avoid duplicate creation 265 match client.get_notebook_by_uri(&stored_uri).await { 266 Ok(Some((uri, refs))) => { 267 tracing::debug!("Found notebook by stored URI: {}", uri); 268 (uri, refs) 269 } 270 Ok(None) | Err(_) => { 271 // Stored URI invalid or notebook deleted, fall back to title lookup 272 tracing::warn!("Stored notebook URI invalid, falling back to title lookup"); 273 let (did, _) = client 274 .session_info() 275 .await 276 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 277 client.upsert_notebook(notebook, &did).await? 278 } 279 } 280 } else { 281 // No stored URI, use title-based lookup/creation 282 let (did, _) = client 283 .session_info() 284 .await 285 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 286 client.upsert_notebook(notebook, &did).await? 287 }; 288 289 // Pass existing rkey if re-publishing (to allow title changes without creating new entry) 290 let doc_entry_ref = doc.entry_ref(); 291 let existing_rkey = doc_entry_ref.as_ref().and_then(|r| r.uri.rkey()); 292 293 // Clone entry for document creation (entry is consumed by upsert). 294 let entry_for_doc = entry.clone(); 295 296 // Use upsert_entry_with_notebook since we already have notebook data 297 let (entry_ref, notebook_uri_final, was_created) = client 298 .upsert_entry_with_notebook( 299 notebook_uri, 300 entry_refs, 301 &doc.title(), 302 entry, 303 existing_rkey.map(|r| r.0.as_str()), 304 ) 305 .await?; 306 let uri = entry_ref.uri.clone(); 307 308 // Set entry_ref so subsequent publishes update this record 309 doc.set_entry_ref(Some(entry_ref.clone())); 310 311 // Store the notebook URI for future re-publishing 312 doc.set_notebook_uri(Some(notebook_uri_final.to_smolstr())); 313 314 // Check if notebook has publishGlobal and create site.standard.document if so. 315 if let Err(e) = 316 maybe_create_document(fetcher, &notebook_uri_final, &entry_ref.uri, &entry_for_doc) 317 .await 318 { 319 tracing::warn!("Failed to create site.standard.document: {}", e); 320 } 321 322 if was_created { 323 PublishResult::Created(uri) 324 } else { 325 PublishResult::Updated(uri) 326 } 327 } else if let Some(existing_ref) = doc.entry_ref() { 328 // Update existing entry (either owner or collaborator) 329 let current_did = fetcher 330 .current_did() 331 .await 332 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 333 334 let rkey = existing_ref 335 .uri 336 .rkey() 337 .ok_or_else(|| WeaverError::InvalidNotebook("Entry URI missing rkey".into()))?; 338 339 // Check if we're the owner or a collaborator 340 let owner_did = match existing_ref.uri.authority() { 341 AtIdentifier::Did(d) => d.clone(), 342 AtIdentifier::Handle(h) => fetcher.client.resolve_handle(h).await.map_err(|e| { 343 WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)) 344 })?, 345 }; 346 let is_collaborator = owner_did != current_did; 347 348 // Rewrite draft image paths to published paths 349 let content = rewrite_draft_paths(&doc.content(), rkey.0.as_str()); 350 351 let entry = Entry::new() 352 .content(content) 353 .title(doc.title()) 354 .path(path) 355 .created_at(Datetime::now()) 356 .updated_at(Datetime::now()) 357 .maybe_tags(tags) 358 .maybe_embeds(entry_embeds) 359 .build(); 360 let entry_data = to_data(&entry).unwrap(); 361 362 let collection = Nsid::new(ENTRY_NSID).map_err(|e| WeaverError::AtprotoString(e))?; 363 364 // Collaborator: create/update in THEIR repo with SAME rkey 365 // Owner: update in their own repo 366 let request = PutRecord::new() 367 .repo(AtIdentifier::Did(current_did.clone())) 368 .collection(collection) 369 .rkey(rkey.clone()) 370 .record(entry_data) 371 .build(); 372 373 let response = fetcher 374 .send(request) 375 .await 376 .map_err(jacquard::client::AgentError::from)?; 377 let output = response 378 .into_output() 379 .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 380 381 if is_collaborator { 382 // Collaborator: don't update doc.entry_ref() - it still points to original 383 // Their version is a parallel record at at://{collab_did}/sh.weaver.notebook.entry/{same_rkey} 384 tracing::info!( 385 "Collaborator published version: {} (original: {})", 386 output.uri, 387 existing_ref.uri 388 ); 389 PublishResult::Created(output.uri.into_static()) 390 } else { 391 // Owner: update entry_ref with new CID 392 let updated_ref = StrongRef::new() 393 .uri(output.uri.clone().into_static()) 394 .cid(output.cid.into_static()) 395 .build(); 396 doc.set_entry_ref(Some(updated_ref)); 397 PublishResult::Updated(output.uri.into_static()) 398 } 399 } else { 400 // Create new free-floating entry - pre-generate rkey for path rewriting 401 let did = fetcher 402 .current_did() 403 .await 404 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 405 406 // Pre-generate TID for the entry rkey 407 let entry_tid = Ticker::new().next(None); 408 let entry_rkey_str = entry_tid.as_str(); 409 410 // Rewrite draft image paths to published paths 411 let content = rewrite_draft_paths(&doc.content(), entry_rkey_str); 412 413 let entry = Entry::new() 414 .content(content) 415 .title(doc.title()) 416 .path(path) 417 .created_at(Datetime::now()) 418 .updated_at(Datetime::now()) 419 .maybe_tags(tags) 420 .maybe_embeds(entry_embeds) 421 .build(); 422 let entry_data = to_data(&entry).unwrap(); 423 424 let collection = Nsid::new(ENTRY_NSID).map_err(|e| WeaverError::AtprotoString(e))?; 425 let rkey = RecordKey::any(entry_rkey_str) 426 .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 427 428 let request = CreateRecord::new() 429 .repo(AtIdentifier::Did(did)) 430 .collection(collection) 431 .rkey(rkey) 432 .record(entry_data) 433 .build(); 434 435 let response = fetcher 436 .send(request) 437 .await 438 .map_err(jacquard::client::AgentError::from)?; 439 let output = response 440 .into_output() 441 .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 442 443 let uri = output.uri.into_static(); 444 // Set entry_ref so subsequent publishes update this record 445 let entry_ref = StrongRef::new() 446 .uri(uri.clone()) 447 .cid(output.cid.into_static()) 448 .build(); 449 doc.set_entry_ref(Some(entry_ref)); 450 PublishResult::Created(uri) 451 }; 452 453 // Cleanup: delete PublishedBlob records (entry's embed refs now keep blobs alive) 454 // TODO: Implement when image upload is added 455 // for img in &editor_images { 456 // if let Some(ref published_uri) = img.published_blob_uri { 457 // let _ = delete_published_blob(fetcher, published_uri).await; 458 // } 459 // } 460 461 // Delete the old draft key 462 delete_draft(draft_key); 463 464 // Save with the new uri-based key so continued editing is tracked by entry URI 465 let new_key = result.uri().to_string(); 466 if let Err(e) = save_to_storage(doc, &new_key) { 467 tracing::warn!("Failed to save draft after publish: {e}"); 468 } 469 470 Ok(result) 471} 472 473/// Check if notebook has publishGlobal enabled and create site.standard.document if so. 474async fn maybe_create_document( 475 fetcher: &Fetcher, 476 notebook_uri: &AtUri<'_>, 477 entry_uri: &AtUri<'_>, 478 entry: &Entry<'_>, 479) -> Result<(), WeaverError> { 480 // Fetch the notebook book record to check publishGlobal. 481 let book = fetch_book_record(fetcher, notebook_uri).await?; 482 483 // Only create document if publishGlobal is enabled. 484 if !book.publish_global.unwrap_or(false) { 485 tracing::debug!("Notebook does not have publishGlobal enabled, skipping document creation"); 486 return Ok(()); 487 } 488 489 // Get the publication URI for this notebook. 490 let publication_uri = publication_uri_for_notebook(notebook_uri) 491 .ok_or_else(|| WeaverError::InvalidNotebook("Could not build publication URI".into()))?; 492 493 // Create the document. 494 match create_document_for_entry(fetcher, entry_uri, entry, &publication_uri).await { 495 Ok(Some(doc_uri)) => { 496 tracing::info!("Created site.standard.document: {}", doc_uri); 497 } 498 Ok(None) => { 499 tracing::debug!("Document creation not needed"); 500 } 501 Err(e) => { 502 tracing::warn!("Failed to create document: {}", e); 503 } 504 } 505 506 Ok(()) 507} 508 509/// Fetch the Book record for a notebook URI. 510async fn fetch_book_record(fetcher: &Fetcher, notebook_uri: &AtUri<'_>) -> Result<Book<'static>, WeaverError> { 511 let rkey = notebook_uri 512 .rkey() 513 .ok_or_else(|| WeaverError::InvalidNotebook("Notebook URI missing rkey".into()))?; 514 515 let collection = Nsid::new(<Book as jacquard::types::collection::Collection>::NSID) 516 .map_err(WeaverError::AtprotoString)?; 517 518 let did = match notebook_uri.authority() { 519 AtIdentifier::Did(d) => d.clone(), 520 AtIdentifier::Handle(h) => fetcher.client.resolve_handle(h).await.map_err(|e| { 521 WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)) 522 })?, 523 }; 524 525 let request = GetRecord::new() 526 .repo(AtIdentifier::Did(did)) 527 .collection(collection) 528 .rkey(RecordKey::any(rkey.as_ref()).map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?) 529 .build(); 530 531 let response = fetcher.send(request).await.map_err(|e| { 532 WeaverError::InvalidNotebook(format!("Failed to fetch notebook: {}", e)) 533 })?; 534 535 let output = response 536 .into_output() 537 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to parse notebook: {}", e)))?; 538 539 let book: Book = jacquard::from_data(&output.value) 540 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to deserialize book: {}", e)))?; 541 542 Ok(book.into_static()) 543} 544 545/// Props for the publish button component. 546#[derive(Props, Clone, PartialEq)] 547pub struct PublishButtonProps { 548 /// The editor document 549 pub document: SignalEditorDocument, 550 /// Storage key for the draft 551 pub draft_key: String, 552 /// Pre-selected notebook (from URL param) 553 #[props(optional)] 554 pub target_notebook: Option<String>, 555} 556 557/// Publish button component with notebook selection. 558#[component] 559pub fn PublishButton(props: PublishButtonProps) -> Element { 560 let fetcher = use_context::<Fetcher>(); 561 let auth_state = use_context::<Signal<AuthState>>(); 562 563 let mut show_dialog = use_signal(|| false); 564 let mut notebook_title = use_signal(|| { 565 props 566 .target_notebook 567 .clone() 568 .unwrap_or_else(|| String::from("Default")) 569 }); 570 let mut use_notebook = use_signal(|| props.target_notebook.is_some()); 571 let mut is_publishing = use_signal(|| false); 572 let mut error_message: Signal<Option<String>> = use_signal(|| None); 573 let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); 574 575 let is_authenticated = auth_state.read().is_authenticated(); 576 let doc = props.document.clone(); 577 let draft_key = props.draft_key.clone(); 578 579 // Check if we're editing an existing entry 580 let is_editing_existing = doc.entry_ref().is_some(); 581 582 // Check if we're publishing as a collaborator (editing someone else's entry) 583 let is_collaborator = { 584 let entry_ref = doc.entry_ref(); 585 let current_did = auth_state.read().did.clone(); 586 match (entry_ref, current_did) { 587 (Some(ref r), Some(ref current)) => { 588 match r.uri.authority() { 589 AtIdentifier::Did(owner_did) => owner_did != current, 590 AtIdentifier::Handle(_) => false, // Can't determine without async resolve 591 } 592 } 593 _ => false, 594 } 595 }; 596 597 // Validate that we have required fields 598 let can_publish = !doc.title().trim().is_empty() && !doc.content().trim().is_empty(); 599 600 let open_dialog = move |_| { 601 error_message.set(None); 602 success_uri.set(None); 603 show_dialog.set(true); 604 }; 605 606 let close_dialog = move |_| { 607 show_dialog.set(false); 608 }; 609 610 let draft_key_clone = draft_key.clone(); 611 let doc_for_publish = doc.clone(); 612 let do_publish = move |_| { 613 let fetcher = fetcher.clone(); 614 let draft_key = draft_key_clone.clone(); 615 let doc_snapshot = doc_for_publish.clone(); 616 let notebook = if use_notebook() { 617 Some(notebook_title()) 618 } else { 619 None 620 }; 621 622 spawn(async move { 623 is_publishing.set(true); 624 error_message.set(None); 625 626 let mut doc_snapshot = doc_snapshot; 627 match publish_entry(&fetcher, &mut doc_snapshot, notebook.as_deref(), &draft_key).await 628 { 629 Ok(result) => { 630 success_uri.set(Some(result.uri().clone())); 631 } 632 Err(e) => { 633 error_message.set(Some(format!("{}", e))); 634 } 635 } 636 637 is_publishing.set(false); 638 }); 639 }; 640 641 rsx! { 642 button { 643 class: "publish-button", 644 disabled: !is_authenticated || !can_publish, 645 onclick: open_dialog, 646 title: if !is_authenticated { 647 "Log in to publish" 648 } else if !can_publish { 649 "Title and content required" 650 } else { 651 "Publish entry" 652 }, 653 "Publish" 654 } 655 656 if show_dialog() { 657 div { 658 class: "publish-dialog-overlay", 659 role: "dialog", 660 aria_modal: "true", 661 aria_labelledby: "publish-dialog-title", 662 onclick: close_dialog, 663 664 div { 665 class: "publish-dialog", 666 onclick: move |e| e.stop_propagation(), 667 668 h2 { id: "publish-dialog-title", "Publish Entry" } 669 670 if let Some(uri) = success_uri() { 671 { 672 // Construct web URL from AT-URI 673 let did = uri.authority(); 674 let web_url = if use_notebook() { 675 // Notebook entry: /{did}/{notebook}/{entry_path} 676 format!("/{}/{}/{}", did, notebook_title(), doc.path()) 677 } else { 678 // Standalone entry: /{did}/e/{rkey} 679 let rkey = uri.rkey().map(|r| r.0.as_str()).unwrap_or(""); 680 format!("/{}/e/{}", did, rkey) 681 }; 682 683 rsx! { 684 div { class: "publish-success", 685 p { "Entry published successfully!" } 686 a { 687 href: "{web_url}", 688 target: "_blank", 689 "View entry → " 690 } 691 button { 692 class: "publish-done", 693 onclick: close_dialog, 694 "Done" 695 } 696 } 697 } 698 } 699 } else { 700 div { class: "publish-form", 701 if is_collaborator { 702 div { class: "publish-info publish-collab-info", 703 p { "Publishing as collaborator" } 704 p { class: "publish-collab-detail", 705 "This creates a version in your repository." 706 } 707 } 708 } else if is_editing_existing { 709 div { class: "publish-info", 710 p { "Updating existing entry" } 711 } 712 } 713 714 div { class: "publish-field publish-checkbox", 715 label { 716 input { 717 r#type: "checkbox", 718 checked: use_notebook(), 719 onchange: move |e| use_notebook.set(e.checked()), 720 } 721 " Publish to notebook" 722 } 723 } 724 725 if use_notebook() { 726 div { class: "publish-field", 727 label { "Notebook" } 728 input { 729 r#type: "text", 730 class: "publish-input", 731 aria_label: "Notebook title", 732 placeholder: "Notebook title...", 733 value: "{notebook_title}", 734 oninput: move |e| notebook_title.set(e.value()), 735 } 736 } 737 } 738 739 div { class: "publish-preview", 740 p { "Title: {doc.title()}" } 741 p { "Path: {doc.path()}" } 742 if !doc.tags().is_empty() { 743 p { "Tags: {doc.tags().join(\", \")}" } 744 } 745 } 746 747 if let Some(err) = error_message() { 748 div { class: "publish-error", 749 "{err}" 750 } 751 } 752 753 div { class: "publish-actions", 754 button { 755 class: "publish-cancel", 756 onclick: close_dialog, 757 disabled: is_publishing(), 758 "Cancel" 759 } 760 button { 761 class: "publish-submit", 762 onclick: do_publish, 763 disabled: is_publishing() || (use_notebook() && notebook_title().trim().is_empty()), 764 if is_publishing() { 765 "Publishing..." 766 } else { 767 "Publish" 768 } 769 } 770 } 771 } 772 } 773 } 774 } 775 } 776 } 777}