atproto blogging
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: ®ex_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: ®ex_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, ¬ebook_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}