atproto blogging
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 (¤t_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}