···4// Any manual changes will be overwritten on the next regeneration.
56pub mod actor;
07pub mod edit;
8pub mod embed;
09pub mod notebook;
10pub mod publish;
···4// Any manual changes will be overwritten on the next regeneration.
56pub mod actor;
7+pub mod collab;
8pub mod edit;
9pub mod embed;
10+pub mod graph;
11pub mod notebook;
12pub mod publish;
···1+// @generated by jacquard-lexicon. DO NOT EDIT.
2+//
3+// Lexicon: sh.weaver.graph.subscribeAccept
4+//
5+// This file was automatically generated from Lexicon schemas.
6+// Any manual changes will be overwritten on the next regeneration.
7+8+/// Acceptance of a subscription request.
9+#[jacquard_derive::lexicon]
10+#[derive(
11+ serde::Serialize,
12+ serde::Deserialize,
13+ Debug,
14+ Clone,
15+ PartialEq,
16+ Eq,
17+ jacquard_derive::IntoStatic
18+)]
19+#[serde(rename_all = "camelCase")]
20+pub struct SubscribeAccept<'a> {
21+ pub created_at: jacquard_common::types::string::Datetime,
22+ /// Reference to the subscribe record being accepted.
23+ #[serde(borrow)]
24+ pub subscribe: crate::com_atproto::repo::strong_ref::StrongRef<'a>,
25+}
26+27+pub mod subscribe_accept_state {
28+29+ pub use crate::builder_types::{Set, Unset, IsSet, IsUnset};
30+ #[allow(unused)]
31+ use ::core::marker::PhantomData;
32+ mod sealed {
33+ pub trait Sealed {}
34+ }
35+ /// State trait tracking which required fields have been set
36+ pub trait State: sealed::Sealed {
37+ type Subscribe;
38+ type CreatedAt;
39+ }
40+ /// Empty state - all required fields are unset
41+ pub struct Empty(());
42+ impl sealed::Sealed for Empty {}
43+ impl State for Empty {
44+ type Subscribe = Unset;
45+ type CreatedAt = Unset;
46+ }
47+ ///State transition - sets the `subscribe` field to Set
48+ pub struct SetSubscribe<S: State = Empty>(PhantomData<fn() -> S>);
49+ impl<S: State> sealed::Sealed for SetSubscribe<S> {}
50+ impl<S: State> State for SetSubscribe<S> {
51+ type Subscribe = Set<members::subscribe>;
52+ type CreatedAt = S::CreatedAt;
53+ }
54+ ///State transition - sets the `created_at` field to Set
55+ pub struct SetCreatedAt<S: State = Empty>(PhantomData<fn() -> S>);
56+ impl<S: State> sealed::Sealed for SetCreatedAt<S> {}
57+ impl<S: State> State for SetCreatedAt<S> {
58+ type Subscribe = S::Subscribe;
59+ type CreatedAt = Set<members::created_at>;
60+ }
61+ /// Marker types for field names
62+ #[allow(non_camel_case_types)]
63+ pub mod members {
64+ ///Marker type for the `subscribe` field
65+ pub struct subscribe(());
66+ ///Marker type for the `created_at` field
67+ pub struct created_at(());
68+ }
69+}
70+71+/// Builder for constructing an instance of this type
72+pub struct SubscribeAcceptBuilder<'a, S: subscribe_accept_state::State> {
73+ _phantom_state: ::core::marker::PhantomData<fn() -> S>,
74+ __unsafe_private_named: (
75+ ::core::option::Option<jacquard_common::types::string::Datetime>,
76+ ::core::option::Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>,
77+ ),
78+ _phantom: ::core::marker::PhantomData<&'a ()>,
79+}
80+81+impl<'a> SubscribeAccept<'a> {
82+ /// Create a new builder for this type
83+ pub fn new() -> SubscribeAcceptBuilder<'a, subscribe_accept_state::Empty> {
84+ SubscribeAcceptBuilder::new()
85+ }
86+}
87+88+impl<'a> SubscribeAcceptBuilder<'a, subscribe_accept_state::Empty> {
89+ /// Create a new builder with all fields unset
90+ pub fn new() -> Self {
91+ SubscribeAcceptBuilder {
92+ _phantom_state: ::core::marker::PhantomData,
93+ __unsafe_private_named: (None, None),
94+ _phantom: ::core::marker::PhantomData,
95+ }
96+ }
97+}
98+99+impl<'a, S> SubscribeAcceptBuilder<'a, S>
100+where
101+ S: subscribe_accept_state::State,
102+ S::CreatedAt: subscribe_accept_state::IsUnset,
103+{
104+ /// Set the `createdAt` field (required)
105+ pub fn created_at(
106+ mut self,
107+ value: impl Into<jacquard_common::types::string::Datetime>,
108+ ) -> SubscribeAcceptBuilder<'a, subscribe_accept_state::SetCreatedAt<S>> {
109+ self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into());
110+ SubscribeAcceptBuilder {
111+ _phantom_state: ::core::marker::PhantomData,
112+ __unsafe_private_named: self.__unsafe_private_named,
113+ _phantom: ::core::marker::PhantomData,
114+ }
115+ }
116+}
117+118+impl<'a, S> SubscribeAcceptBuilder<'a, S>
119+where
120+ S: subscribe_accept_state::State,
121+ S::Subscribe: subscribe_accept_state::IsUnset,
122+{
123+ /// Set the `subscribe` field (required)
124+ pub fn subscribe(
125+ mut self,
126+ value: impl Into<crate::com_atproto::repo::strong_ref::StrongRef<'a>>,
127+ ) -> SubscribeAcceptBuilder<'a, subscribe_accept_state::SetSubscribe<S>> {
128+ self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into());
129+ SubscribeAcceptBuilder {
130+ _phantom_state: ::core::marker::PhantomData,
131+ __unsafe_private_named: self.__unsafe_private_named,
132+ _phantom: ::core::marker::PhantomData,
133+ }
134+ }
135+}
136+137+impl<'a, S> SubscribeAcceptBuilder<'a, S>
138+where
139+ S: subscribe_accept_state::State,
140+ S::Subscribe: subscribe_accept_state::IsSet,
141+ S::CreatedAt: subscribe_accept_state::IsSet,
142+{
143+ /// Build the final struct
144+ pub fn build(self) -> SubscribeAccept<'a> {
145+ SubscribeAccept {
146+ created_at: self.__unsafe_private_named.0.unwrap(),
147+ subscribe: self.__unsafe_private_named.1.unwrap(),
148+ extra_data: Default::default(),
149+ }
150+ }
151+ /// Build the final struct with custom extra_data
152+ pub fn build_with_data(
153+ self,
154+ extra_data: std::collections::BTreeMap<
155+ jacquard_common::smol_str::SmolStr,
156+ jacquard_common::types::value::Data<'a>,
157+ >,
158+ ) -> SubscribeAccept<'a> {
159+ SubscribeAccept {
160+ created_at: self.__unsafe_private_named.0.unwrap(),
161+ subscribe: self.__unsafe_private_named.1.unwrap(),
162+ extra_data: Some(extra_data),
163+ }
164+ }
165+}
166+167+impl<'a> SubscribeAccept<'a> {
168+ pub fn uri(
169+ uri: impl Into<jacquard_common::CowStr<'a>>,
170+ ) -> Result<
171+ jacquard_common::types::uri::RecordUri<'a, SubscribeAcceptRecord>,
172+ jacquard_common::types::uri::UriError,
173+ > {
174+ jacquard_common::types::uri::RecordUri::try_from_uri(
175+ jacquard_common::types::string::AtUri::new_cow(uri.into())?,
176+ )
177+ }
178+}
179+180+/// Typed wrapper for GetRecord response with this collection's record type.
181+#[derive(
182+ serde::Serialize,
183+ serde::Deserialize,
184+ Debug,
185+ Clone,
186+ PartialEq,
187+ Eq,
188+ jacquard_derive::IntoStatic
189+)]
190+#[serde(rename_all = "camelCase")]
191+pub struct SubscribeAcceptGetRecordOutput<'a> {
192+ #[serde(skip_serializing_if = "std::option::Option::is_none")]
193+ #[serde(borrow)]
194+ pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>,
195+ #[serde(borrow)]
196+ pub uri: jacquard_common::types::string::AtUri<'a>,
197+ #[serde(borrow)]
198+ pub value: SubscribeAccept<'a>,
199+}
200+201+impl From<SubscribeAcceptGetRecordOutput<'_>> for SubscribeAccept<'_> {
202+ fn from(output: SubscribeAcceptGetRecordOutput<'_>) -> Self {
203+ use jacquard_common::IntoStatic;
204+ output.value.into_static()
205+ }
206+}
207+208+impl jacquard_common::types::collection::Collection for SubscribeAccept<'_> {
209+ const NSID: &'static str = "sh.weaver.graph.subscribeAccept";
210+ type Record = SubscribeAcceptRecord;
211+}
212+213+/// Marker type for deserializing records from this collection.
214+#[derive(Debug, serde::Serialize, serde::Deserialize)]
215+pub struct SubscribeAcceptRecord;
216+impl jacquard_common::xrpc::XrpcResp for SubscribeAcceptRecord {
217+ const NSID: &'static str = "sh.weaver.graph.subscribeAccept";
218+ const ENCODING: &'static str = "application/json";
219+ type Output<'de> = SubscribeAcceptGetRecordOutput<'de>;
220+ type Err<'de> = jacquard_common::types::collection::RecordError<'de>;
221+}
222+223+impl jacquard_common::types::collection::Collection for SubscribeAcceptRecord {
224+ const NSID: &'static str = "sh.weaver.graph.subscribeAccept";
225+ type Record = SubscribeAcceptRecord;
226+}
227+228+impl<'a> ::jacquard_lexicon::schema::LexiconSchema for SubscribeAccept<'a> {
229+ fn nsid() -> &'static str {
230+ "sh.weaver.graph.subscribeAccept"
231+ }
232+ fn def_name() -> &'static str {
233+ "main"
234+ }
235+ fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
236+ lexicon_doc_sh_weaver_graph_subscribeAccept()
237+ }
238+ fn validate(
239+ &self,
240+ ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> {
241+ Ok(())
242+ }
243+}
244+245+fn lexicon_doc_sh_weaver_graph_subscribeAccept() -> ::jacquard_lexicon::lexicon::LexiconDoc<
246+ 'static,
247+> {
248+ ::jacquard_lexicon::lexicon::LexiconDoc {
249+ lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1,
250+ id: ::jacquard_common::CowStr::new_static("sh.weaver.graph.subscribeAccept"),
251+ revision: None,
252+ description: None,
253+ defs: {
254+ let mut map = ::std::collections::BTreeMap::new();
255+ map.insert(
256+ ::jacquard_common::smol_str::SmolStr::new_static("main"),
257+ ::jacquard_lexicon::lexicon::LexUserType::Record(::jacquard_lexicon::lexicon::LexRecord {
258+ description: Some(
259+ ::jacquard_common::CowStr::new_static(
260+ "Acceptance of a subscription request.",
261+ ),
262+ ),
263+ key: Some(::jacquard_common::CowStr::new_static("tid")),
264+ record: ::jacquard_lexicon::lexicon::LexRecordRecord::Object(::jacquard_lexicon::lexicon::LexObject {
265+ description: None,
266+ required: Some(
267+ vec![
268+ ::jacquard_common::smol_str::SmolStr::new_static("subscribe"),
269+ ::jacquard_common::smol_str::SmolStr::new_static("createdAt")
270+ ],
271+ ),
272+ nullable: None,
273+ properties: {
274+ #[allow(unused_mut)]
275+ let mut map = ::std::collections::BTreeMap::new();
276+ map.insert(
277+ ::jacquard_common::smol_str::SmolStr::new_static(
278+ "createdAt",
279+ ),
280+ ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
281+ description: None,
282+ format: Some(
283+ ::jacquard_lexicon::lexicon::LexStringFormat::Datetime,
284+ ),
285+ default: None,
286+ min_length: None,
287+ max_length: None,
288+ min_graphemes: None,
289+ max_graphemes: None,
290+ r#enum: None,
291+ r#const: None,
292+ known_values: None,
293+ }),
294+ );
295+ map.insert(
296+ ::jacquard_common::smol_str::SmolStr::new_static(
297+ "subscribe",
298+ ),
299+ ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef {
300+ description: None,
301+ r#ref: ::jacquard_common::CowStr::new_static(
302+ "com.atproto.repo.strongRef",
303+ ),
304+ }),
305+ );
306+ map
307+ },
308+ }),
309+ }),
310+ );
311+ map
312+ },
313+ }
314+}
+4
crates/weaver-api/src/sh_weaver/notebook.rs
···10pub mod chapter;
11pub mod colour_scheme;
12pub mod entry;
000013pub mod page;
14pub mod theme;
15
···10pub mod chapter;
11pub mod colour_scheme;
12pub mod entry;
13+pub mod get_entry;
14+pub mod get_entry_by_title;
15+pub mod get_notebook;
16+pub mod get_notebook_by_title;
17pub mod page;
18pub mod theme;
19
···1+//! Action buttons for entries (edit, delete, remove from notebook).
2+3+use crate::Route;
4+use crate::auth::AuthState;
5+use crate::components::button::{Button, ButtonVariant};
6+use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle};
7+use crate::fetch::Fetcher;
8+use dioxus::prelude::*;
9+use jacquard::smol_str::SmolStr;
10+use jacquard::types::aturi::AtUri;
11+use jacquard::types::ident::AtIdentifier;
12+use jacquard::IntoStatic;
13+use weaver_api::com_atproto::repo::delete_record::DeleteRecord;
14+use weaver_api::com_atproto::repo::put_record::PutRecord;
15+16+const ENTRY_ACTIONS_CSS: Asset = asset!("/assets/styling/entry-actions.css");
17+18+#[derive(Props, Clone, PartialEq)]
19+pub struct EntryActionsProps {
20+ /// The AT-URI of the entry
21+ pub entry_uri: AtUri<'static>,
22+ /// The entry title (for display in confirmation)
23+ pub entry_title: String,
24+ /// Whether this entry is in a notebook (enables "remove from notebook")
25+ #[props(default = false)]
26+ pub in_notebook: bool,
27+ /// Notebook title (if in_notebook is true, used for edit route)
28+ #[props(default)]
29+ pub notebook_title: Option<SmolStr>,
30+ /// Callback when entry is removed from notebook (for optimistic UI update)
31+ #[props(default)]
32+ pub on_removed: Option<EventHandler<()>>,
33+}
34+35+/// Action buttons for an entry: edit, delete, optionally remove from notebook.
36+#[component]
37+pub fn EntryActions(props: EntryActionsProps) -> Element {
38+ let auth_state = use_context::<Signal<AuthState>>();
39+ let fetcher = use_context::<Fetcher>();
40+ let navigator = use_navigator();
41+42+ let mut show_delete_confirm = use_signal(|| false);
43+ let mut show_remove_confirm = use_signal(|| false);
44+ let mut show_dropdown = use_signal(|| false);
45+ let mut deleting = use_signal(|| false);
46+ let mut removing = use_signal(|| false);
47+ let mut error = use_signal(|| None::<String>);
48+49+ // Check ownership - compare auth DID with entry's authority
50+ let current_did = auth_state.read().did.clone();
51+ let entry_authority = props.entry_uri.authority();
52+ let is_owner = match (¤t_did, entry_authority) {
53+ (Some(current), AtIdentifier::Did(entry_did)) => *current == *entry_did,
54+ _ => false,
55+ };
56+57+ if !is_owner {
58+ return rsx! {};
59+ }
60+61+ // Extract rkey from URI for edit route
62+ let rkey = match props.entry_uri.rkey() {
63+ Some(r) => r.0.to_string(),
64+ None => return rsx! {}, // Can't edit without rkey
65+ };
66+67+ // Build edit route based on whether entry is in a notebook
68+ let ident = props.entry_uri.authority().clone();
69+ let edit_route = if props.in_notebook {
70+ if let Some(ref notebook) = props.notebook_title {
71+ Route::NotebookEntryEdit {
72+ ident: ident.into_static(),
73+ book_title: notebook.clone(),
74+ rkey: rkey.clone().into(),
75+ }
76+ } else {
77+ Route::StandaloneEntryEdit {
78+ ident: ident.into_static(),
79+ rkey: rkey.clone().into(),
80+ }
81+ }
82+ } else {
83+ Route::StandaloneEntryEdit {
84+ ident: ident.into_static(),
85+ rkey: rkey.clone().into(),
86+ }
87+ };
88+89+ let entry_uri_for_delete = props.entry_uri.clone();
90+ let entry_title = props.entry_title.clone();
91+92+ let delete_fetcher = fetcher.clone();
93+ let handle_delete = move |_| {
94+ let fetcher = delete_fetcher.clone();
95+ let uri = entry_uri_for_delete.clone();
96+ let navigator = navigator.clone();
97+98+ spawn(async move {
99+ use jacquard::prelude::*;
100+101+ deleting.set(true);
102+ error.set(None);
103+104+ let client = fetcher.get_client();
105+ let collection = uri.collection();
106+ let rkey = uri.rkey();
107+108+ if let (Some(collection), Some(rkey)) = (collection, rkey) {
109+ let did = match fetcher.current_did().await {
110+ Some(d) => d,
111+ None => {
112+ error.set(Some("Not authenticated".to_string()));
113+ deleting.set(false);
114+ return;
115+ }
116+ };
117+118+ let request = DeleteRecord::new()
119+ .repo(AtIdentifier::Did(did))
120+ .collection(collection.clone())
121+ .rkey(rkey.clone())
122+ .build();
123+124+ match client.send(request).await {
125+ Ok(_) => {
126+ show_delete_confirm.set(false);
127+ // Navigate back to home after delete
128+ navigator.push(Route::Home {});
129+ }
130+ Err(e) => {
131+ error.set(Some(format!("Delete failed: {:?}", e)));
132+ }
133+ }
134+ } else {
135+ error.set(Some("Invalid entry URI".to_string()));
136+ }
137+ deleting.set(false);
138+ });
139+ };
140+141+ // Handler for removing entry from notebook (keeps entry, just removes from notebook's list)
142+ let entry_uri_for_remove = props.entry_uri.clone();
143+ let notebook_title_for_remove = props.notebook_title.clone();
144+ let on_removed = props.on_removed.clone();
145+ let handle_remove_from_notebook = move |_| {
146+ let fetcher = fetcher.clone();
147+ let entry_uri = entry_uri_for_remove.clone();
148+ let notebook_title = notebook_title_for_remove.clone();
149+ let on_removed = on_removed.clone();
150+151+ spawn(async move {
152+ use jacquard::{from_data, to_data, prelude::*, types::string::Nsid};
153+ use weaver_api::sh_weaver::notebook::book::Book;
154+155+ let client = fetcher.get_client();
156+157+ removing.set(true);
158+ error.set(None);
159+160+ let notebook_title = match notebook_title {
161+ Some(t) => t,
162+ None => {
163+ error.set(Some("No notebook specified".to_string()));
164+ removing.set(false);
165+ return;
166+ }
167+ };
168+169+ let did = match fetcher.current_did().await {
170+ Some(d) => d,
171+ None => {
172+ error.set(Some("Not authenticated".to_string()));
173+ removing.set(false);
174+ return;
175+ }
176+ };
177+178+ // Get the notebook by title
179+ let ident = AtIdentifier::Did(did.clone());
180+ let notebook_result = fetcher.get_notebook(ident.clone(), notebook_title.clone()).await;
181+182+ let (notebook_view, _) = match notebook_result {
183+ Ok(Some(data)) => data.as_ref().clone(),
184+ Ok(None) => {
185+ error.set(Some("Notebook not found".to_string()));
186+ removing.set(false);
187+ return;
188+ }
189+ Err(e) => {
190+ error.set(Some(format!("Failed to get notebook: {:?}", e)));
191+ removing.set(false);
192+ return;
193+ }
194+ };
195+196+ // Parse the book record to get the entry_list
197+ let mut book: Book = match from_data(¬ebook_view.record) {
198+ Ok(b) => b,
199+ Err(e) => {
200+ error.set(Some(format!("Failed to parse notebook: {:?}", e)));
201+ removing.set(false);
202+ return;
203+ }
204+ };
205+206+ // Filter out the entry
207+ let entry_uri_str = entry_uri.as_str();
208+ let original_len = book.entry_list.len();
209+ book.entry_list.retain(|ref_| ref_.uri.as_str() != entry_uri_str);
210+211+ if book.entry_list.len() == original_len {
212+ error.set(Some("Entry not found in notebook".to_string()));
213+ removing.set(false);
214+ return;
215+ }
216+217+ // Get the notebook's rkey from its URI
218+ let notebook_rkey = match notebook_view.uri.rkey() {
219+ Some(r) => r,
220+ None => {
221+ error.set(Some("Invalid notebook URI".to_string()));
222+ removing.set(false);
223+ return;
224+ }
225+ };
226+227+ // Convert book to Data for the request
228+ let book_data = match to_data(&book) {
229+ Ok(d) => d,
230+ Err(e) => {
231+ error.set(Some(format!("Failed to serialize notebook: {:?}", e)));
232+ removing.set(false);
233+ return;
234+ }
235+ };
236+237+ // Update the notebook record
238+ let request = PutRecord::new()
239+ .repo(AtIdentifier::Did(did))
240+ .collection(Nsid::new_static("sh.weaver.notebook.book").unwrap())
241+ .rkey(notebook_rkey.clone())
242+ .record(book_data)
243+ .build();
244+245+ match client.send(request).await {
246+ Ok(_) => {
247+ show_remove_confirm.set(false);
248+ // Notify parent to remove from local state
249+ if let Some(handler) = &on_removed {
250+ handler.call(());
251+ }
252+ }
253+ Err(e) => {
254+ error.set(Some(format!("Failed to update notebook: {:?}", e)));
255+ }
256+ }
257+ removing.set(false);
258+ });
259+ };
260+261+ rsx! {
262+ document::Link { rel: "stylesheet", href: ENTRY_ACTIONS_CSS }
263+264+ div { class: "entry-actions",
265+ // Edit button (always visible for owner)
266+ Link {
267+ to: edit_route,
268+ class: "entry-action-link",
269+ Button {
270+ variant: ButtonVariant::Ghost,
271+ "Edit"
272+ }
273+ }
274+275+ // Dropdown for destructive actions
276+ div { class: "entry-actions-dropdown",
277+ Button {
278+ variant: ButtonVariant::Ghost,
279+ onclick: move |_| show_dropdown.toggle(),
280+ "⋮"
281+ }
282+283+ if show_dropdown() {
284+ div { class: "dropdown-menu",
285+ if props.in_notebook {
286+ button {
287+ class: "dropdown-item",
288+ onclick: move |_| {
289+ show_dropdown.set(false);
290+ show_remove_confirm.set(true);
291+ },
292+ "Remove from notebook"
293+ }
294+ }
295+ button {
296+ class: "dropdown-item dropdown-item-danger",
297+ onclick: move |_| {
298+ show_dropdown.set(false);
299+ show_delete_confirm.set(true);
300+ },
301+ "Delete"
302+ }
303+ }
304+ }
305+ }
306+307+ // Delete confirmation dialog
308+ DialogRoot {
309+ open: show_delete_confirm(),
310+ on_open_change: move |open: bool| show_delete_confirm.set(open),
311+ DialogContent {
312+ DialogTitle { "Delete Entry?" }
313+ DialogDescription {
314+ "Delete \"{entry_title}\"? This removes the published entry. You can restore from drafts if needed."
315+ }
316+ if let Some(ref err) = error() {
317+ div { class: "dialog-error", "{err}" }
318+ }
319+ div { class: "dialog-actions",
320+ Button {
321+ variant: ButtonVariant::Destructive,
322+ onclick: handle_delete,
323+ disabled: deleting(),
324+ if deleting() { "Deleting..." } else { "Delete" }
325+ }
326+ Button {
327+ variant: ButtonVariant::Ghost,
328+ onclick: move |_| show_delete_confirm.set(false),
329+ "Cancel"
330+ }
331+ }
332+ }
333+ }
334+335+ // Remove from notebook confirmation dialog
336+ if props.in_notebook {
337+ {
338+ let entry_title_for_remove = entry_title.clone();
339+ rsx! {
340+ DialogRoot {
341+ open: show_remove_confirm(),
342+ on_open_change: move |open: bool| show_remove_confirm.set(open),
343+ DialogContent {
344+ DialogTitle { "Remove from Notebook?" }
345+ DialogDescription {
346+ "Remove \"{entry_title_for_remove}\" from this notebook? The entry will still exist but won't be part of this notebook."
347+ }
348+ if let Some(ref err) = error() {
349+ div { class: "dialog-error", "{err}" }
350+ }
351+ div { class: "dialog-actions",
352+ Button {
353+ variant: ButtonVariant::Primary,
354+ onclick: handle_remove_from_notebook,
355+ disabled: removing(),
356+ if removing() { "Removing..." } else { "Remove" }
357+ }
358+ Button {
359+ variant: ButtonVariant::Ghost,
360+ onclick: move |_| show_remove_confirm.set(false),
361+ "Cancel"
362+ }
363+ }
364+ }
365+ }
366+ }
367+ }
368+ }
369+ }
370+ }
371+}
+119-40
crates/weaver-app/src/components/identity.rs
···001use crate::{Route, data, fetch};
2use dioxus::prelude::*;
3use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier};
···4748 // Main content area
49 main { class: "repository-main",
00050 div { class: "notebooks-list",
51 match &*notebooks.read() {
52 Some(notebook_list) => rsx! {
···72 }
73 }
74 }
00075 }
76 }
77}
···84 use jacquard::IntoStatic;
8586 let fetcher = use_context::<fetch::Fetcher>();
08788 let title = notebook
89 .title
···91 .map(|t| t.as_ref())
92 .unwrap_or("Untitled Notebook");
93000000000094 // Format date
95 let formatted_date = notebook.indexed_at.as_ref().format("%B %d, %Y").to_string();
96···126 class: "notebook-card-header-link",
127128 div { class: "notebook-card-header",
129- h2 { class: "notebook-card-title", "{title}" }
000000000130131 div { class: "notebook-card-date",
132 time { datetime: "{notebook.indexed_at.as_str()}", "{formatted_date}" }
···199 let created_at = from_data::<Entry>(&entry_view.entry.record).ok()
200 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
201202- rsx! {
203- Link {
204- to: Route::EntryPage {
205- ident: ident.clone(),
206- book_title: book_title.clone(),
207- title: entry_title.to_string().into()
208- },
209- class: "notebook-entry-preview-link",
210211- div { class: "notebook-entry-preview",
212- div { class: "entry-preview-header",
00000000213 div { class: "entry-preview-title", "{entry_title}" }
214- if let Some(ref date) = created_at {
215- div { class: "entry-preview-date", "{date}" }
00000000216 }
217 }
218- if let Some(ref html) = preview_html {
00000000219 div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
220 }
221 }
···243 let created_at = from_data::<Entry>(&first_entry.entry.record).ok()
244 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
24500246 rsx! {
247- Link {
248- to: Route::EntryPage {
249- ident: ident.clone(),
250- book_title: book_title.clone(),
251- title: entry_title.to_string().into()
252- },
253- class: "notebook-entry-preview-link",
254-255- div { class: "notebook-entry-preview notebook-entry-preview-first",
256- div { class: "entry-preview-header",
257 div { class: "entry-preview-title", "{entry_title}" }
258- if let Some(ref date) = created_at {
259- div { class: "entry-preview-date", "{date}" }
00000000260 }
261 }
262- if let Some(ref html) = preview_html {
00000000263 div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
264 }
265 }
···296 let created_at = from_data::<Entry>(&last_entry.entry.record).ok()
297 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
29800299 rsx! {
300- Link {
301- to: Route::EntryPage {
302- ident: ident.clone(),
303- book_title: book_title.clone(),
304- title: entry_title.to_string().into()
305- },
306- class: "notebook-entry-preview-link",
307-308- div { class: "notebook-entry-preview notebook-entry-preview-last",
309- div { class: "entry-preview-header",
310 div { class: "entry-preview-title", "{entry_title}" }
311- if let Some(ref date) = created_at {
312- div { class: "entry-preview-date", "{date}" }
00000000313 }
314 }
315- if let Some(ref html) = preview_html {
00000000316 div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
317 }
318 }
···1+use crate::auth::AuthState;
2+use crate::components::{ProfileActions, ProfileActionsMenubar};
3use crate::{Route, data, fetch};
4use dioxus::prelude::*;
5use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier};
···4950 // Main content area
51 main { class: "repository-main",
52+ // Mobile menubar (hidden on desktop)
53+ ProfileActionsMenubar { ident }
54+55 div { class: "notebooks-list",
56 match &*notebooks.read() {
57 Some(notebook_list) => rsx! {
···77 }
78 }
79 }
80+81+ // Actions sidebar (desktop only)
82+ ProfileActions { ident }
83 }
84 }
85}
···92 use jacquard::IntoStatic;
9394 let fetcher = use_context::<fetch::Fetcher>();
95+ let auth_state = use_context::<Signal<AuthState>>();
9697 let title = notebook
98 .title
···100 .map(|t| t.as_ref())
101 .unwrap_or("Untitled Notebook");
102103+ // Check ownership for "Add Entry" link
104+ let notebook_ident = notebook.uri.authority().clone().into_static();
105+ let is_owner = {
106+ let current_did = auth_state.read().did.clone();
107+ match (¤t_did, ¬ebook_ident) {
108+ (Some(did), AtIdentifier::Did(nb_did)) => *did == *nb_did,
109+ _ => false,
110+ }
111+ };
112+113 // Format date
114 let formatted_date = notebook.indexed_at.as_ref().format("%B %d, %Y").to_string();
115···145 class: "notebook-card-header-link",
146147 div { class: "notebook-card-header",
148+ div { class: "notebook-card-header-top",
149+ h2 { class: "notebook-card-title", "{title}" }
150+ if is_owner {
151+ Link {
152+ to: Route::NewDraft { ident: notebook_ident.clone(), notebook: Some(book_title.clone()) },
153+ class: "notebook-add-entry",
154+ "+ Add"
155+ }
156+ }
157+ }
158159 div { class: "notebook-card-date",
160 time { datetime: "{notebook.indexed_at.as_str()}", "{formatted_date}" }
···227 let created_at = from_data::<Entry>(&entry_view.entry.record).ok()
228 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
229230+ let entry_uri = entry_view.entry.uri.clone().into_static();
0000000231232+ rsx! {
233+ div { class: "notebook-entry-preview",
234+ div { class: "entry-preview-header",
235+ Link {
236+ to: Route::EntryPage {
237+ ident: ident.clone(),
238+ book_title: book_title.clone(),
239+ title: entry_title.to_string().into()
240+ },
241+ class: "entry-preview-title-link",
242 div { class: "entry-preview-title", "{entry_title}" }
243+ }
244+ if let Some(ref date) = created_at {
245+ div { class: "entry-preview-date", "{date}" }
246+ }
247+ if is_owner {
248+ crate::components::EntryActions {
249+ entry_uri,
250+ entry_title: entry_title.to_string(),
251+ in_notebook: true,
252+ notebook_title: Some(book_title.clone())
253 }
254 }
255+ }
256+ if let Some(ref html) = preview_html {
257+ Link {
258+ to: Route::EntryPage {
259+ ident: ident.clone(),
260+ book_title: book_title.clone(),
261+ title: entry_title.to_string().into()
262+ },
263+ class: "entry-preview-content-link",
264 div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
265 }
266 }
···288 let created_at = from_data::<Entry>(&first_entry.entry.record).ok()
289 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
290291+ let entry_uri = first_entry.entry.uri.clone().into_static();
292+293 rsx! {
294+ div { class: "notebook-entry-preview notebook-entry-preview-first",
295+ div { class: "entry-preview-header",
296+ Link {
297+ to: Route::EntryPage {
298+ ident: ident.clone(),
299+ book_title: book_title.clone(),
300+ title: entry_title.to_string().into()
301+ },
302+ class: "entry-preview-title-link",
0303 div { class: "entry-preview-title", "{entry_title}" }
304+ }
305+ if let Some(ref date) = created_at {
306+ div { class: "entry-preview-date", "{date}" }
307+ }
308+ if is_owner {
309+ crate::components::EntryActions {
310+ entry_uri,
311+ entry_title: entry_title.to_string(),
312+ in_notebook: true,
313+ notebook_title: Some(book_title.clone())
314 }
315 }
316+ }
317+ if let Some(ref html) = preview_html {
318+ Link {
319+ to: Route::EntryPage {
320+ ident: ident.clone(),
321+ book_title: book_title.clone(),
322+ title: entry_title.to_string().into()
323+ },
324+ class: "entry-preview-content-link",
325 div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
326 }
327 }
···358 let created_at = from_data::<Entry>(&last_entry.entry.record).ok()
359 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
360361+ let entry_uri = last_entry.entry.uri.clone().into_static();
362+363 rsx! {
364+ div { class: "notebook-entry-preview notebook-entry-preview-last",
365+ div { class: "entry-preview-header",
366+ Link {
367+ to: Route::EntryPage {
368+ ident: ident.clone(),
369+ book_title: book_title.clone(),
370+ title: entry_title.to_string().into()
371+ },
372+ class: "entry-preview-title-link",
0373 div { class: "entry-preview-title", "{entry_title}" }
374+ }
375+ if let Some(ref date) = created_at {
376+ div { class: "entry-preview-date", "{date}" }
377+ }
378+ if is_owner {
379+ crate::components::EntryActions {
380+ entry_uri,
381+ entry_title: entry_title.to_string(),
382+ in_notebook: true,
383+ notebook_title: Some(book_title.clone())
384 }
385 }
386+ }
387+ if let Some(ref html) = preview_html {
388+ Link {
389+ to: Route::EntryPage {
390+ ident: ident.clone(),
391+ book_title: book_title.clone(),
392+ title: entry_title.to_string().into()
393+ },
394+ class: "entry-preview-content-link",
395 div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
396 }
397 }
+5
crates/weaver-app/src/components/mod.rs
···128pub mod button;
129pub mod dialog;
130pub mod editor;
0131pub mod input;
0000
···128pub mod button;
129pub mod dialog;
130pub mod editor;
131+pub mod entry_actions;
132pub mod input;
133+pub mod profile_actions;
134+135+pub use entry_actions::EntryActions;
136+pub use profile_actions::{ProfileActions, ProfileActionsMenubar};
···1+//! Actions sidebar/menubar for profile page.
2+3+use crate::Route;
4+use crate::auth::AuthState;
5+use crate::components::button::{Button, ButtonVariant};
6+use dioxus::prelude::*;
7+use jacquard::types::ident::AtIdentifier;
8+9+const PROFILE_ACTIONS_CSS: Asset = asset!("/assets/styling/profile-actions.css");
10+11+/// Actions available on the profile page for the owner.
12+#[component]
13+pub fn ProfileActions(ident: ReadSignal<AtIdentifier<'static>>) -> Element {
14+ let auth_state = use_context::<Signal<AuthState>>();
15+16+ // Check if viewing own profile
17+ let is_owner = {
18+ let current_did = auth_state.read().did.clone();
19+ match (¤t_did, ident()) {
20+ (Some(did), AtIdentifier::Did(ref ident_did)) => *did == *ident_did,
21+ _ => false,
22+ }
23+ };
24+25+ if !is_owner {
26+ return rsx! {};
27+ }
28+29+ rsx! {
30+ document::Link { rel: "stylesheet", href: PROFILE_ACTIONS_CSS }
31+32+ aside { class: "profile-actions",
33+ div { class: "profile-actions-container",
34+ div { class: "profile-actions-list",
35+ Link {
36+ to: Route::NewDraft { ident: ident(), notebook: None },
37+ class: "profile-action-link",
38+ Button {
39+ variant: ButtonVariant::Outline,
40+ "New Entry"
41+ }
42+ }
43+44+ // TODO: New Notebook button (disabled for now)
45+ Button {
46+ variant: ButtonVariant::Outline,
47+ disabled: true,
48+ "New Notebook"
49+ }
50+51+ Link {
52+ to: Route::DraftsList { ident: ident() },
53+ class: "profile-action-link",
54+ Button {
55+ variant: ButtonVariant::Ghost,
56+ "Drafts"
57+ }
58+ }
59+ }
60+ }
61+ }
62+ }
63+}
64+65+/// Mobile-friendly menubar version of profile actions.
66+#[component]
67+pub fn ProfileActionsMenubar(ident: ReadSignal<AtIdentifier<'static>>) -> Element {
68+ let auth_state = use_context::<Signal<AuthState>>();
69+70+ let is_owner = {
71+ let current_did = auth_state.read().did.clone();
72+ match (¤t_did, ident()) {
73+ (Some(did), AtIdentifier::Did(ref ident_did)) => *did == *ident_did,
74+ _ => false,
75+ }
76+ };
77+78+ if !is_owner {
79+ return rsx! {};
80+ }
81+82+ rsx! {
83+ div { class: "profile-actions-menubar",
84+ Link {
85+ to: Route::NewDraft { ident: ident(), notebook: None },
86+ Button {
87+ variant: ButtonVariant::Primary,
88+ "New Entry"
89+ }
90+ }
91+92+ Link {
93+ to: Route::DraftsList { ident: ident() },
94+ Button {
95+ variant: ButtonVariant::Ghost,
96+ "Drafts"
97+ }
98+ }
99+ }
100+ }
101+}
+84-2
crates/weaver-app/src/data.rs
···518 None
519 }
520 });
521- (res, memo);
522}
523524/// Fetches notebooks for a specific DID
···644/// Fetches notebooks from UFOS client-side only (no SSR)
645#[cfg(not(feature = "fullstack-server"))]
646pub fn use_notebooks_from_ufos() -> (
647- Resource<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
648 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
649) {
650 let fetcher = use_context::<crate::fetch::Fetcher>();
···808 });
809 let memo = use_memo(move || r.read().as_ref().and_then(|v| v.clone()));
810 (r, memo)
0000000000000000000000000000000000000000000000000000000000000000000000000000000000811}
812813#[cfg(feature = "fullstack-server")]
···518 None
519 }
520 });
521+ (res, memo)
522}
523524/// Fetches notebooks for a specific DID
···644/// Fetches notebooks from UFOS client-side only (no SSR)
645#[cfg(not(feature = "fullstack-server"))]
646pub fn use_notebooks_from_ufos() -> (
647+ Resource<Option<Vec<serde_json::Value>>>,
648 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>,
649) {
650 let fetcher = use_context::<crate::fetch::Fetcher>();
···808 });
809 let memo = use_memo(move || r.read().as_ref().and_then(|v| v.clone()));
810 (r, memo)
811+}
812+813+// ============================================================================
814+// Ownership Checking
815+// ============================================================================
816+817+/// Check if the current authenticated user owns a resource identified by an AtIdentifier.
818+///
819+/// Returns a memo that is:
820+/// - `Some(true)` if the user is authenticated and their DID matches the resource owner
821+/// - `Some(false)` if the user is authenticated but doesn't match, or resource is a handle
822+/// - `None` if the user is not authenticated
823+///
824+/// For handles, this does a synchronous check that returns `false` since we can't resolve
825+/// handles synchronously. Use `use_is_owner_async` for handle resolution.
826+pub fn use_is_owner(resource_owner: ReadSignal<AtIdentifier<'static>>) -> Memo<Option<bool>> {
827+ let auth_state = use_context::<Signal<AuthState>>();
828+829+ use_memo(move || {
830+ let current_did = auth_state.read().did.clone()?;
831+ let owner = resource_owner();
832+833+ match owner {
834+ AtIdentifier::Did(did) => Some(did == current_did),
835+ AtIdentifier::Handle(_) => Some(false), // Can't resolve synchronously
836+ }
837+ })
838+}
839+840+/// Check ownership with async handle resolution.
841+///
842+/// Returns a resource that resolves to:
843+/// - `Some(true)` if the user owns the resource
844+/// - `Some(false)` if the user doesn't own the resource
845+/// - `None` if the user is not authenticated
846+#[cfg(feature = "fullstack-server")]
847+pub fn use_is_owner_async(
848+ resource_owner: ReadSignal<AtIdentifier<'static>>,
849+) -> Resource<Option<bool>> {
850+ let auth_state = use_context::<Signal<AuthState>>();
851+ let fetcher = use_context::<crate::fetch::Fetcher>();
852+853+ use_resource(move || {
854+ let fetcher = fetcher.clone();
855+ let owner = resource_owner();
856+ async move {
857+ let current_did = auth_state.read().did.clone()?;
858+859+ match owner {
860+ AtIdentifier::Did(did) => Some(did == current_did),
861+ AtIdentifier::Handle(handle) => match fetcher.resolve_handle(&handle).await {
862+ Ok(resolved_did) => Some(resolved_did == current_did),
863+ Err(_) => Some(false),
864+ },
865+ }
866+ }
867+ })
868+}
869+870+/// Check ownership with async handle resolution (client-only mode).
871+#[cfg(not(feature = "fullstack-server"))]
872+pub fn use_is_owner_async(
873+ resource_owner: ReadSignal<AtIdentifier<'static>>,
874+) -> Resource<Option<bool>> {
875+ let auth_state = use_context::<Signal<AuthState>>();
876+ let fetcher = use_context::<crate::fetch::Fetcher>();
877+878+ use_resource(move || {
879+ let fetcher = fetcher.clone();
880+ let owner = resource_owner();
881+ async move {
882+ let current_did = auth_state.read().did.clone()?;
883+884+ match owner {
885+ AtIdentifier::Did(did) => Some(did == current_did),
886+ AtIdentifier::Handle(handle) => match fetcher.resolve_handle(&handle).await {
887+ Ok(resolved_did) => Some(resolved_did == current_did),
888+ Err(_) => Some(false),
889+ },
890+ }
891+ }
892+ })
893}
894895#[cfg(feature = "fullstack-server")]
···35 "type": "string",
36 "format": "datetime",
37 "description": "Client-declared timestamp when this was originally created."
0000038 }
39 }
40 }
···35 "type": "string",
36 "format": "datetime",
37 "description": "Client-declared timestamp when this was originally created."
38+ },
39+ "updatedAt": {
40+ "type": "string",
41+ "format": "datetime",
42+ "description": "Client-declared timestamp of last modification. Used for canonicality tiebreaking in multi-author scenarios."
43 }
44 }
45 }
+5
lexicons/notebook/entry.json
···25 "format": "datetime",
26 "description": "Client-declared timestamp when this was originally created."
27 },
0000028 "embeds": {
29 "type": "object",
30 "description": "The set of images and records, if any, embedded in the notebook entry.",
···25 "format": "datetime",
26 "description": "Client-declared timestamp when this was originally created."
27 },
28+ "updatedAt": {
29+ "type": "string",
30+ "format": "datetime",
31+ "description": "Client-declared timestamp of last modification. Used for canonicality tiebreaking in multi-author scenarios."
32+ },
33 "embeds": {
34 "type": "object",
35 "description": "The set of images and records, if any, embedded in the notebook entry.",
+35
lexicons/notebook/getEntry.json
···00000000000000000000000000000000000
···1+{
2+ "lexicon": 1,
3+ "id": "sh.weaver.notebook.getEntry",
4+ "defs": {
5+ "main": {
6+ "type": "query",
7+ "description": "Get an entry view by notebook URI and index, including prev/next navigation.",
8+ "parameters": {
9+ "type": "params",
10+ "required": ["notebook"],
11+ "properties": {
12+ "notebook": {
13+ "type": "string",
14+ "format": "at-uri",
15+ "description": "AT-URI of the notebook containing the entry."
16+ },
17+ "index": {
18+ "type": "integer",
19+ "minimum": 0,
20+ "default": 0,
21+ "description": "Zero-based index of the entry in the notebook's entry list."
22+ }
23+ }
24+ },
25+ "output": {
26+ "encoding": "application/json",
27+ "schema": {
28+ "type": "ref",
29+ "ref": "sh.weaver.notebook.defs#bookEntryView"
30+ }
31+ },
32+ "errors": [{ "name": "NotebookNotFound" }, { "name": "EntryNotFound" }]
33+ }
34+ }
35+}