at main 373 lines 14 kB view raw
1//! Drafts and standalone entry views. 2 3use crate::Route; 4use crate::auth::AuthState; 5use crate::components::button::{Button, ButtonVariant}; 6use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; 7use crate::components::editor::{list_drafts_from_pds, RemoteDraft}; 8use crate::components::editor::{delete_draft, delete_draft_from_pds, list_drafts}; 9use crate::fetch::Fetcher; 10use dioxus::prelude::*; 11use jacquard::smol_str::{SmolStr, format_smolstr}; 12use jacquard::types::ident::AtIdentifier; 13use std::collections::HashSet; 14 15const DRAFTS_CSS: Asset = asset!("/assets/styling/drafts.css"); 16 17/// Merged draft entry showing both local and remote state. 18#[derive(Clone, Debug, PartialEq)] 19struct MergedDraft { 20 /// The rkey/tid of the draft 21 rkey: String, 22 /// Title from local storage (if available) 23 title: String, 24 /// Whether this draft exists locally 25 is_local: bool, 26 /// Whether this draft exists on PDS 27 is_remote: bool, 28 /// If editing an existing entry, the URI 29 editing_uri: Option<String>, 30} 31 32/// Drafts list page - shows all drafts for the authenticated user. 33#[component] 34pub fn DraftsList(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 35 // ALL hooks must be called unconditionally at the top 36 let auth_state = use_context::<Signal<AuthState>>(); 37 let fetcher = use_context::<Fetcher>(); 38 let navigator = use_navigator(); 39 let mut local_drafts = use_signal(list_drafts); 40 let mut show_delete_confirm = use_signal(|| None::<String>); 41 42 // Clone fetcher early for use in both resource and delete handler 43 let fetcher_for_resource = fetcher.clone(); 44 let fetcher_for_delete = fetcher.clone(); 45 46 // Fetch remote drafts from PDS (depends on auth state to re-run when logged in) 47 let remote_drafts_resource = use_resource(move || { 48 let fetcher = fetcher_for_resource.clone(); 49 let _did = auth_state.read().did.clone(); // Track auth state for reactivity 50 async move { list_drafts_from_pds(&fetcher).await.ok().unwrap_or_default() } 51 }); 52 53 // Check ownership - redirect if not viewing own drafts 54 let current_did = auth_state.read().did.clone(); 55 let is_owner = match (&current_did, ident()) { 56 (Some(did), AtIdentifier::Did(ref ident_did)) => *did == *ident_did, 57 _ => false, 58 }; 59 60 // Redirect non-owners 61 let ident_for_redirect = ident(); 62 use_effect(move || { 63 if !is_owner { 64 navigator.replace(Route::RepositoryIndex { 65 ident: ident_for_redirect.clone(), 66 }); 67 } 68 }); 69 70 if !is_owner { 71 return rsx! { div { "Redirecting..." } }; 72 } 73 74 // Merge local and remote drafts 75 let merged_drafts = use_memo(move || { 76 let local = local_drafts(); 77 let remote: Vec<RemoteDraft> = remote_drafts_resource().unwrap_or_default(); 78 79 tracing::debug!("Merging drafts: {} local, {} remote", local.len(), remote.len()); 80 for (key, _, _) in &local { 81 tracing::debug!(" Local draft key: {}", key); 82 } 83 for rd in &remote { 84 tracing::debug!(" Remote draft rkey: {}", rd.rkey); 85 } 86 87 // Build set of remote rkeys for quick lookup 88 let remote_rkeys: HashSet<String> = remote.iter().map(|d| d.rkey.clone()).collect(); 89 90 // Build set of local rkeys 91 let local_rkeys: HashSet<String> = local 92 .iter() 93 .map(|(key, _, _)| { 94 key.strip_prefix("new:").unwrap_or(key).to_string() 95 }) 96 .collect(); 97 98 let mut merged = Vec::new(); 99 100 // Add local drafts 101 for (key, title, editing_uri) in &local { 102 let rkey = key.strip_prefix("new:").unwrap_or(key).to_string(); 103 merged.push(MergedDraft { 104 rkey: rkey.clone(), 105 title: title.clone(), 106 is_local: true, 107 is_remote: remote_rkeys.contains(&rkey), 108 editing_uri: editing_uri.clone(), 109 }); 110 } 111 112 // Add remote-only drafts 113 for remote_draft in &remote { 114 if !local_rkeys.contains(&remote_draft.rkey) { 115 tracing::info!("Adding remote-only draft: {}", remote_draft.rkey); 116 merged.push(MergedDraft { 117 rkey: remote_draft.rkey.clone(), 118 title: String::new(), // No local title available 119 is_local: false, 120 is_remote: true, 121 editing_uri: None, 122 }); 123 } 124 } 125 126 // Sort by rkey (which is a TID, so newer drafts first) 127 merged.sort_by(|a, b| b.rkey.cmp(&a.rkey)); 128 129 tracing::info!("Merged {} drafts total", merged.len()); 130 for m in &merged { 131 tracing::info!(" Merged: rkey={} is_local={} is_remote={}", m.rkey, m.is_local, m.is_remote); 132 } 133 134 merged 135 }); 136 137 let mut handle_delete = move |key: String| { 138 let fetcher = fetcher_for_delete.clone(); 139 let key_clone = key.clone(); 140 141 // Delete from localStorage immediately 142 delete_draft(&key); 143 local_drafts.set(list_drafts()); 144 show_delete_confirm.set(None); 145 146 // Also delete from PDS (async, fire-and-forget) 147 spawn(async move { 148 if let Err(e) = delete_draft_from_pds(&fetcher, &key_clone).await { 149 tracing::warn!("Failed to delete draft from PDS: {}", e); 150 } 151 }); 152 }; 153 154 rsx! { 155 document::Link { rel: "stylesheet", href: DRAFTS_CSS } 156 document::Title { "Drafts" } 157 158 div { class: "drafts-page", 159 div { class: "drafts-header", 160 h1 { "Drafts" } 161 Link { 162 to: Route::NewDraft { ident: ident(), notebook: None }, 163 Button { 164 variant: ButtonVariant::Primary, 165 "New Draft" 166 } 167 } 168 } 169 170 if merged_drafts().is_empty() { 171 div { class: "drafts-empty", 172 p { "No drafts yet." } 173 p { "Start writing something new!" } 174 } 175 } else { 176 div { class: "drafts-list", 177 for draft in merged_drafts() { 178 { 179 let key_for_delete = format_smolstr!("new:{}", draft.rkey).to_string(); 180 let is_edit_draft = draft.editing_uri.is_some(); 181 let display_title = if draft.title.is_empty() { 182 "Untitled".to_string() 183 } else { 184 draft.title.clone() 185 }; 186 187 // Determine sync status badge 188 let (sync_badge, sync_class) = match (draft.is_local, draft.is_remote) { 189 (true, true) => ("Synced", "draft-badge-synced"), 190 (true, false) => ("Local", "draft-badge-local"), 191 (false, true) => ("Remote", "draft-badge-remote"), 192 (false, false) => ("", ""), // shouldn't happen 193 }; 194 tracing::info!("Rendering draft {} - badge='{}' class='{}'", draft.rkey, sync_badge, sync_class); 195 196 rsx! { 197 div { 198 class: "draft-card", 199 key: "{draft.rkey}", 200 201 Link { 202 to: Route::DraftEdit { 203 ident: ident(), 204 tid: draft.rkey.clone().into(), 205 }, 206 class: "draft-card-link", 207 208 div { class: "draft-card-content", 209 h3 { class: "draft-title", "{display_title}" } 210 div { class: "draft-badges", 211 if is_edit_draft { 212 span { class: "draft-badge draft-badge-edit", "Editing" } 213 } 214 if !sync_badge.is_empty() { 215 span { class: "draft-badge {sync_class}", "{sync_badge}" } 216 } 217 } 218 } 219 } 220 221 if draft.is_local { 222 Button { 223 variant: ButtonVariant::Ghost, 224 onclick: move |_| show_delete_confirm.set(Some(key_for_delete.clone())), 225 "×" 226 } 227 } 228 } 229 } 230 } 231 } 232 } 233 } 234 } 235 236 // Delete confirmation 237 DialogRoot { 238 open: show_delete_confirm().is_some(), 239 on_open_change: move |_: bool| show_delete_confirm.set(None), 240 DialogContent { 241 DialogTitle { "Delete Draft?" } 242 DialogDescription { 243 "This will permanently delete this draft." 244 } 245 div { class: "dialog-actions", 246 Button { 247 variant: ButtonVariant::Destructive, 248 onclick: move |_| { 249 if let Some(key) = show_delete_confirm() { 250 handle_delete(key); 251 } 252 }, 253 "Delete" 254 } 255 Button { 256 variant: ButtonVariant::Ghost, 257 onclick: move |_| show_delete_confirm.set(None), 258 "Cancel" 259 } 260 } 261 } 262 } 263 } 264} 265 266/// Edit an existing draft by TID. 267#[component] 268pub fn DraftEdit(ident: ReadSignal<AtIdentifier<'static>>, tid: ReadSignal<SmolStr>) -> Element { 269 use crate::components::editor::MarkdownEditor; 270 use crate::views::editor::EditorCss; 271 272 // Draft key for "new" drafts is "new:{tid}" 273 let draft_key = format!("new:{}", tid()); 274 275 rsx! { 276 EditorCss {} 277 div { class: "editor-page", 278 MarkdownEditor { entry_uri: Some(draft_key), target_notebook: None } 279 } 280 } 281} 282 283/// Create a new draft. 284#[component] 285pub fn NewDraft( 286 ident: ReadSignal<AtIdentifier<'static>>, 287 notebook: ReadSignal<Option<SmolStr>>, 288) -> Element { 289 use crate::components::editor::MarkdownEditor; 290 use crate::views::editor::EditorCss; 291 292 rsx! { 293 EditorCss {} 294 div { class: "editor-page", 295 MarkdownEditor { 296 entry_uri: None, 297 target_notebook: notebook() 298 } 299 } 300 } 301} 302 303/// Edit a standalone entry. 304#[component] 305pub fn StandaloneEntryEdit( 306 ident: ReadSignal<AtIdentifier<'static>>, 307 rkey: ReadSignal<SmolStr>, 308) -> Element { 309 use crate::components::editor::MarkdownEditor; 310 use crate::views::editor::EditorCss; 311 312 // Construct AT-URI for the entry 313 let entry_uri = 314 use_memo(move || format_smolstr!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey()).to_string()); 315 316 rsx! { 317 EditorCss {} 318 div { class: "editor-page", 319 MarkdownEditor { entry_uri: Some(entry_uri()), target_notebook: None } 320 } 321 } 322} 323 324/// Edit a notebook entry by rkey. 325#[component] 326pub fn NotebookEntryEdit( 327 ident: ReadSignal<AtIdentifier<'static>>, 328 book_title: ReadSignal<SmolStr>, 329 rkey: ReadSignal<SmolStr>, 330) -> Element { 331 use crate::components::editor::MarkdownEditor; 332 use crate::data::use_notebook_entries; 333 use crate::views::editor::EditorCss; 334 use weaver_common::EntryIndex; 335 336 // Construct AT-URI for the entry 337 let entry_uri = 338 use_memo(move || format_smolstr!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey()).to_string()); 339 340 // Fetch notebook entries for wikilink validation 341 let (_entries_resource, entries_memo) = use_notebook_entries(ident, book_title); 342 343 // Build entry index from notebook entries 344 let entry_index = use_memo(move || { 345 entries_memo().map(|entries| { 346 let mut index = EntryIndex::new(); 347 let ident_str = ident().to_string(); 348 let book = book_title(); 349 for book_entry in &entries { 350 // EntryView has optional title/path 351 let title = book_entry.entry.title.as_ref().map(|t| t.as_str()).unwrap_or(""); 352 let path = book_entry.entry.path.as_ref().map(|p| p.as_str()).unwrap_or(""); 353 if !title.is_empty() || !path.is_empty() { 354 // Build canonical URL: /{ident}/{book}/{path} 355 let canonical_url = format_smolstr!("/{}/{}/{}", ident_str, book, path).to_string(); 356 index.add_entry(title, path, canonical_url); 357 } 358 } 359 index 360 }) 361 }); 362 363 rsx! { 364 EditorCss {} 365 div { class: "editor-page", 366 MarkdownEditor { 367 entry_uri: Some(entry_uri()), 368 target_notebook: Some(book_title()), 369 entry_index: entry_index(), 370 } 371 } 372 } 373}