diff --git a/Cargo.lock b/Cargo.lock index 1a65bb2..9b5246c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8692,6 +8692,7 @@ dependencies = [ "sqlite-wasm-rs", "time", "tokio", + "tracing", "wasm-bindgen", "wasm-bindgen-futures", "weaver-api", diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 43c3552..d55e300 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -47,6 +47,7 @@ tokio = { version = "1.28", features = ["sync"] } dioxus-logger = "0.7.1" serde_html_form = "0.2.8" webbrowser = "1.0.6" +tracing.workspace = true diff --git a/crates/weaver-app/assets/styling/main.css b/crates/weaver-app/assets/styling/main.css index 9e58e2a..2355c61 100644 --- a/crates/weaver-app/assets/styling/main.css +++ b/crates/weaver-app/assets/styling/main.css @@ -7,3 +7,36 @@ body { #header { max-width: 1200px; } + +.record-view-container { + max-width: 1200px; + margin: 2rem auto; + padding: 0 1rem; +} + +.uri-input-section { + margin-bottom: 2.5rem; +} + +.uri-input { + font-family: var(--font-mono); + font-size: 0.9rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 0.5rem 0.75rem; + background: var(--color-surface, rgba(0, 0, 0, 0.2)); + border: 1px solid var(--color-border); + color: var(--color-text); + outline: none; + transition: border-color 0.2s; +} + +.uri-input:focus { + border-color: var(--color-primary); +} + +.uri-input::placeholder { + color: var(--color-subtle); + opacity: 0.5; +} diff --git a/crates/weaver-app/assets/styling/navbar.css b/crates/weaver-app/assets/styling/navbar.css index 85320db..bb47509 100644 --- a/crates/weaver-app/assets/styling/navbar.css +++ b/crates/weaver-app/assets/styling/navbar.css @@ -2,9 +2,9 @@ display: flex; flex-direction: row; justify-content: space-between; - padding-left: 4rem; + padding-left: 1rem; padding-top: 1rem; - padding-right: 4rem; + padding-right: 1rem; } .breadcrumbs { diff --git a/crates/weaver-app/assets/styling/record-view.css b/crates/weaver-app/assets/styling/record-view.css index 2b01e21..cf8ee52 100644 --- a/crates/weaver-app/assets/styling/record-view.css +++ b/crates/weaver-app/assets/styling/record-view.css @@ -21,6 +21,33 @@ letter-spacing: 0.15em; } +.uri-input-section { + margin-bottom: 1rem; +} + +.uri-input { + font-family: var(--font-mono); + font-size: 0.9rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 0.5rem 0.75rem; + background: var(--color-surface, rgba(0, 0, 0, 0.2)); + border: 1px solid var(--color-border); + color: var(--color-text); + outline: none; + transition: border-color 0.2s; +} + +.uri-input:focus { + border-color: var(--color-primary); +} + +.uri-input::placeholder { + color: var(--color-subtle); + opacity: 0.5; +} + .record-metadata { display: flex-wrap; flex-direction: column; @@ -807,3 +834,40 @@ font-style: italic; padding-top: 0.25rem; } + +/* Dialog Actions for Delete Confirmation */ +.dialog-actions { + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 0; + margin-top: 8px; +} + +.dialog-actions button { + font-family: var(--font-mono); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.5rem 1rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--color-subtle); + cursor: pointer; + transition: all 0.2s; +} + +.dialog-actions button:hover { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +.dialog-actions button:first-child { + color: var(--color-error, #ff6b6b); +} + +.dialog-actions button:first-child:hover { + color: var(--color-error, #ff5252); + border-bottom-color: var(--color-error, #ff6b6b); +} diff --git a/crates/weaver-app/src/components/login.rs b/crates/weaver-app/src/components/login.rs index 2164035..b3fb62d 100644 --- a/crates/weaver-app/src/components/login.rs +++ b/crates/weaver-app/src/components/login.rs @@ -44,12 +44,16 @@ pub fn LoginModal(open: Signal) -> Element { let handle = handle.clone(); let fetcher = fetcher.clone(); spawn(async move { - if let Err(e) = start_oauth_flow(handle, fetcher).await { - error!("Authentication failed: {}", e); - error.set(Some(format!("Authentication failed: {}", e))); - is_loading.set(false); + match start_oauth_flow(handle, fetcher).await { + Ok(_) => { + open.set(false); + } + Err(e) => { + error!("Authentication failed: {}", e); + error.set(Some(format!("Authentication failed: {}", e))); + is_loading.set(false); + } } - open.set(false); }); }); }; diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index 1a2daf6..5298de6 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -9,7 +9,10 @@ use dioxus::fullstack::FullstackContext; #[cfg(all(feature = "fullstack-server", feature = "server"))] use dioxus::fullstack::response::Extension; use dioxus_logger::tracing::Level; -use jacquard::oauth::{client::OAuthClient, session::ClientData}; +use jacquard::{ + oauth::{client::OAuthClient, session::ClientData}, + types::aturi::AtUri, +}; #[allow(unused)] use jacquard::{ smol_str::SmolStr, @@ -19,7 +22,9 @@ use jacquard::{ use std::sync::Arc; use std::sync::{LazyLock, Mutex}; #[allow(unused)] -use views::{Callback, Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordView}; +use views::{ + Callback, Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordIndex, RecordView, +}; use crate::{ auth::{AuthState, AuthStore}, @@ -56,8 +61,12 @@ enum Route { #[route("/")] Home {}, #[layout(ErrorLayout)] - #[route("/record#:uri")] - RecordView { uri: SmolStr }, + #[nest("/record")] + #[layout(RecordIndex)] + #[route("/:..uri")] + RecordView { uri: Vec }, + #[end_layout] + #[end_nest] #[route("/callback?:state&:iss&:code")] Callback { state: SmolStr, iss: SmolStr, code: SmolStr }, #[nest("/:ident")] diff --git a/crates/weaver-app/src/views/home.rs b/crates/weaver-app/src/views/home.rs index 650a2cc..1e01738 100644 --- a/crates/weaver-app/src/views/home.rs +++ b/crates/weaver-app/src/views/home.rs @@ -1,5 +1,6 @@ -use crate::{components::identity::NotebookCard, fetch}; +use crate::{Route, components::identity::NotebookCard, fetch}; use dioxus::prelude::*; +use jacquard::{IntoStatic, smol_str::ToSmolStr, types::aturi::AtUri}; const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); @@ -13,36 +14,68 @@ pub fn Home() -> Element { let fetcher = fetcher.clone(); async move { fetcher.fetch_notebooks_from_ufos().await } }); + let navigator = use_navigator(); + let mut uri_input = use_signal(|| String::new()); + + let handle_uri_submit = move || { + let input_uri = uri_input.read().clone(); + if !input_uri.is_empty() { + if let Ok(parsed) = AtUri::new(&input_uri) { + navigator.push(Route::RecordView { + uri: vec![parsed.to_string()], + }); + } + } + }; rsx! { document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } - - div { class: "notebooks-list", - match notebooks() { - Some(Ok(notebook_list)) => rsx! { - for notebook in notebook_list.iter() { - { - let view = ¬ebook.0; - let entries = ¬ebook.1; - rsx! { - div { - key: "{view.cid}", - NotebookCard { - notebook: view.clone(), - entry_refs: entries.clone() + div { + class: "record-view-container", + div { class: "record-header", + div { class: "uri-input-section", + input { + r#type: "text", + class: "uri-input", + placeholder: "at://did:plc:.../collection/rkey", + value: "{uri_input}", + oninput: move |evt| uri_input.set(evt.value()), + onkeydown: move |evt| { + if evt.key() == Key::Enter { + handle_uri_submit(); + } + }, + } + } + } + div { class: "notebooks-list", + match notebooks() { + Some(Ok(notebook_list)) => rsx! { + for notebook in notebook_list.iter() { + { + let view = ¬ebook.0; + let entries = ¬ebook.1; + rsx! { + div { + key: "{view.cid}", + NotebookCard { + notebook: view.clone(), + entry_refs: entries.clone() + } } } } } + }, + Some(Err(_)) => rsx! { + div { "Error loading notebooks" } + }, + None => rsx! { + div { "Loading notebooks..." } } - }, - Some(Err(_)) => rsx! { - div { "Error loading notebooks" } - }, - None => rsx! { - div { "Loading notebooks..." } } } } + } } diff --git a/crates/weaver-app/src/views/mod.rs b/crates/weaver-app/src/views/mod.rs index 428cd98..fbd78b7 100644 --- a/crates/weaver-app/src/views/mod.rs +++ b/crates/weaver-app/src/views/mod.rs @@ -21,7 +21,7 @@ mod notebook; pub use notebook::{Notebook, NotebookIndex}; mod record; -pub use record::RecordView; +pub use record::{RecordIndex, RecordView}; mod callback; pub use callback::Callback; diff --git a/crates/weaver-app/src/views/record.rs b/crates/weaver-app/src/views/record.rs index 9972c51..cec7878 100644 --- a/crates/weaver-app/src/views/record.rs +++ b/crates/weaver-app/src/views/record.rs @@ -2,7 +2,7 @@ use crate::Route; use crate::auth::AuthState; use crate::components::dialog::{DialogContent, DialogDescription, DialogRoot, DialogTitle}; use crate::fetch::CachedFetcher; -use dioxus::prelude::*; +use dioxus::{CapturedError, prelude::*}; use humansize::format_size; use jacquard::api::com_atproto::repo::get_record::GetRecordOutput; use jacquard::client::AgentError; @@ -12,7 +12,6 @@ use jacquard::{ client::AgentSessionExt, common::{Data, IntoStatic}, identity::lexicon_resolver::LexiconSchemaResolver, - smol_str::SmolStr, types::{aturi::AtUri, cid::Cid, ident::AtIdentifier, string::Nsid}, }; use mime_sniffer::MimeTypeSniffer; @@ -28,28 +27,62 @@ enum ViewMode { } #[component] -pub fn RecordView(uri: ReadSignal) -> Element { - let fetcher = use_context::(); - let at_uri = AtUri::new_owned(uri()); - if let Err(err) = &at_uri { - let error = format!("{:?}", err); - return rsx! { - div { +pub fn RecordIndex() -> Element { + let navigator = use_navigator(); + let mut uri_input = use_signal(|| String::new()); + + let handle_uri_submit = move || { + let input_uri = uri_input.read().clone(); + if !input_uri.is_empty() { + if let Ok(parsed) = AtUri::new(&input_uri) { + let link = format!("{}/record/{}", crate::env::WEAVER_APP_DOMAIN, parsed); + navigator.push(link); + } + } + }; + rsx! { + document::Stylesheet { href: asset!("/assets/styling/record-view.css") } + div { + class: "record-view-container", + div { class: "record-header", h1 { "Record View" } - p { "URI: {uri}" } - p { "Error: {error}" } + div { class: "uri-input-section", + input { + r#type: "text", + class: "uri-input", + placeholder: "at://did:plc:.../collection/rkey", + value: "{uri_input}", + oninput: move |evt| uri_input.set(evt.value()), + onkeydown: move |evt| { + if evt.key() == Key::Enter { + handle_uri_submit(); + } + }, + } + } } - }; + + Outlet:: {} + } + } +} + +#[component] +pub fn RecordView(uri: ReadSignal>) -> Element { + let fetcher = use_context::(); + info!("Uri:{:?}", uri().join("/")); + let at_uri = AtUri::new_owned(&*uri.read().join("/")); + if at_uri.is_err() { + return rsx! {}; } - let uri = use_signal(|| at_uri.unwrap()); + let uri = use_signal(move || AtUri::new_owned(&*uri.read().join("/")).unwrap()); let mut view_mode = use_signal(|| ViewMode::Pretty); let mut edit_mode = use_signal(|| false); - let navigator = use_navigator(); let client = fetcher.get_client(); let record_resource = use_resource(move || { let client = client.clone(); - async move { client.fetch_record_slingshot(&uri()).await } + async move { client.fetch_record_slingshot(&*uri.read()).await } }); // Check ownership for edit access @@ -61,7 +94,7 @@ pub fn RecordView(uri: ReadSignal) -> Element { } // authority() returns &AtIdentifier which can be Did or Handle - match uri().authority() { + match &*uri.read().authority() { AtIdentifier::Did(record_did) => auth.did.as_ref() == Some(record_did), AtIdentifier::Handle(_) => { // Can't easily check ownership for handles without async resolution @@ -69,70 +102,71 @@ pub fn RecordView(uri: ReadSignal) -> Element { } } }); - if let Some(Ok(record)) = &*record_resource.read_unchecked() { - let mut record_value = use_signal(|| record.value.clone().into_static()); - let json = - use_memo(move || serde_json::to_string_pretty(&record_value()).unwrap_or_default()); + if let Some(Ok(record)) = &*record_resource.read() { + let record_value = record.value.clone().into_static(); + let record = record.clone(); rsx! { - RecordViewLayout { - uri: uri().clone(), - cid: record.cid.clone(), - if edit_mode() { - EditableRecordContent { - record_value: record_value, - uri: uri, - view_mode: view_mode, - edit_mode: edit_mode, - record_resource: record_resource, - } - } else { - div { - class: "tab-bar", - button { - class: if view_mode() == ViewMode::Pretty { "tab-button active" } else { "tab-button" }, - onclick: move |_| view_mode.set(ViewMode::Pretty), - "View" - } - button { - class: if view_mode() == ViewMode::Json { "tab-button active" } else { "tab-button" }, - onclick: move |_| view_mode.set(ViewMode::Json), - "JSON" + Fragment { key: "{uri()}", + RecordViewLayout { + uri: uri().clone(), + cid: record.cid.clone(), + if edit_mode() { + + EditableRecordContent { + record_value: record_value, + uri: uri, + view_mode: view_mode, + edit_mode: edit_mode, + record_resource: record_resource, } - if is_owner() { + } else { + div { + class: "tab-bar", button { - class: "tab-button edit-button", - onclick: move |_| edit_mode.set(true), - "Edit" + class: if view_mode() == ViewMode::Pretty { "tab-button active" } else { "tab-button" }, + onclick: move |_| view_mode.set(ViewMode::Pretty), + "View" } - } - } - div { - class: "tab-content", - match view_mode() { - ViewMode::Pretty => rsx! { - PrettyRecordView { record: record_value(), uri: uri().clone() } - }, - ViewMode::Json => rsx! { - CodeView { - code: use_signal(|| json()), - lang: Some("json".to_string()), + button { + class: if view_mode() == ViewMode::Json { "tab-button active" } else { "tab-button" }, + onclick: move |_| view_mode.set(ViewMode::Json), + "JSON" + } + if is_owner() { + button { + class: "tab-button edit-button", + onclick: move |_| edit_mode.set(true), + "Edit" } - }, + } + } + div { + class: "tab-content", + match view_mode() { + ViewMode::Pretty => rsx! { + PrettyRecordView { record: record_value, uri: uri().clone() } + }, + ViewMode::Json => { + let json = use_memo(use_reactive!(|record| serde_json::to_string_pretty( + &record.value + ) + .unwrap_or_default())); + rsx! { + CodeView { + code: json, + lang: Some("json".to_string()), + } + } + }, + } } } } } } } else { - rsx! { - div { - class: "record-view-container", - h1 { "Record" } - p { "URI: {uri}" } - p { "Loading..." } - } - } + rsx! {} } } @@ -390,13 +424,13 @@ fn DataView(data: Data<'static>, path: String, did: String) -> Element { #[component] fn HighlightedUri(uri: AtUri<'static>) -> Element { let s = uri.as_str(); - let link = format!("/record#{}", s); + let link = format!("{}/record/{}", crate::env::WEAVER_APP_DOMAIN, s); if let Some(rest) = s.strip_prefix("at://") { let parts: Vec<&str> = rest.splitn(3, '/').collect(); return rsx! { a { - href: "{link}", + href: link, class: "uri-link", span { class: "string-at-uri", span { class: "aturi-scheme", "at://" } @@ -419,7 +453,7 @@ fn HighlightedUri(uri: AtUri<'static>) -> Element { }; } - rsx! { span { class: "string-at-uri", "{s}" } } + rsx! { a { class: "string-at-uri", href: s } } } #[component] @@ -2070,43 +2104,37 @@ fn AddFieldWidget(root: Signal>, path: String) -> Element { #[component] fn RecordViewLayout(uri: AtUri<'static>, cid: Option>, children: Element) -> Element { rsx! { - document::Stylesheet { href: asset!("/assets/styling/record-view.css") } div { - class: "record-view-container", - div { - class: "record-header", - h1 { "Record" } - div { - class: "record-metadata", - div { class: "metadata-row", - span { class: "metadata-label", "URI" } - span { class: "metadata-value", - HighlightedUri { uri: uri.clone() } - } - } - if let Some(cid) = cid { - div { class: "metadata-row", - span { class: "metadata-label", "CID" } - code { class: "metadata-value", "{cid}" } - } - } + class: "record-metadata", + div { class: "metadata-row", + span { class: "metadata-label", "URI" } + span { class: "metadata-value", + HighlightedUri { uri: uri.clone() } + } + } + if let Some(cid) = cid { + div { class: "metadata-row", + span { class: "metadata-label", "CID" } + code { class: "metadata-value", "{cid}" } } } - {children} } + + {children} + } } /// Render some text as markdown. #[component] fn EditableRecordContent( - record_value: Signal>, + record_value: Data<'static>, uri: ReadSignal>, view_mode: Signal, edit_mode: Signal, record_resource: Resource, AgentError>>, ) -> Element { - let mut edit_data = use_signal(|| record_value()); + let mut edit_data = use_signal(use_reactive!(|record_value| record_value.clone())); let nsid = use_memo(move || edit_data().type_discriminator().map(|s| s.to_string())); let navigator = use_navigator(); let fetcher = use_context::(); @@ -2150,7 +2178,7 @@ fn EditableRecordContent( Ok(output) => { if output.status() == StatusCode::OK { dioxus_logger::tracing::info!("Record updated successfully"); - record_value.set(data); + edit_data.set(data.clone()); edit_mode.set(false); } else { dioxus_logger::tracing::error!("Unexpected status code: {:?}", output.status()); @@ -2184,7 +2212,8 @@ fn EditableRecordContent( Ok(response) => { if let Ok(output) = response.into_output() { dioxus_logger::tracing::info!("Record created: {}", output.uri); - nav.push(Route::RecordView { uri: output.uri.to_smolstr() }); + let link = format!("{}/record/{}", crate::env::WEAVER_APP_DOMAIN, output.uri); + nav.push(link); } } Err(e) => { @@ -2233,7 +2262,8 @@ fn EditableRecordContent( } dioxus_logger::tracing::info!("Record replaced: {}", create_output.uri); - nav.push(Route::RecordView { uri: create_output.uri.to_smolstr() }); + let link = format!("{}/record/{}", crate::env::WEAVER_APP_DOMAIN, create_output.uri); + nav.push(link); } } Err(e) => { @@ -2275,7 +2305,7 @@ fn EditableRecordContent( }); }, on_cancel: move |_| { - edit_data.set(record_value()); + edit_data.set(record_value.clone()); edit_mode.set(false); }, }