diff --git a/.gitignore b/.gitignore index 83b57892..dd6385bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store /generated/ /target/ -/web-distribution/ \ No newline at end of file +/web-distribution/ +.codex/ diff --git a/definy-client/src/lib.rs b/definy-client/src/lib.rs index f849ea54..60ad3b94 100644 --- a/definy-client/src/lib.rs +++ b/definy-client/src/lib.rs @@ -57,22 +57,19 @@ fn read_ssr_state() -> Option<( >, )>, bool, + Vec>, )> { let text = SSR_INITIAL_STATE_TEXT.as_ref()?.to_string(); let decoded = definy_ui::decode_ssr_state(text.as_str())?; - Some( - ( - decoded - .event_binaries - .into_iter() - .map(|bytes| { - let hash: [u8; 32] = ::digest(&bytes).into(); - (hash, definy_event::verify_and_deserialize(&bytes)) - }) - .collect(), - decoded.has_more, - ), - ) + let event_binaries = decoded.event_binaries; + let events = event_binaries + .iter() + .map(|bytes| { + let hash: [u8; 32] = ::digest(bytes).into(); + (hash, definy_event::verify_and_deserialize(bytes)) + }) + .collect(); + Some((events, decoded.has_more, event_binaries)) } impl narumincho_vdom_client::App for DefinyApp { @@ -81,6 +78,7 @@ impl narumincho_vdom_client::App for DefinyApp { ) -> AppState { let fire = std::rc::Rc::clone(fire); let ssr_state = read_ssr_state(); + let ssr_event_binaries = ssr_state.as_ref().map(|(_, _, binaries)| binaries.clone()); let has_ssr_events = ssr_state.is_some(); let fire_for_keydown = std::rc::Rc::clone(&fire); @@ -113,7 +111,62 @@ impl narumincho_vdom_client::App for DefinyApp { let filter_for_fetch = definy_ui::event_filter_from_query_str(&filter_query); wasm_bindgen_futures::spawn_local(async move { + if let Some(ssr_event_binaries) = ssr_event_binaries { + let event_pairs = ssr_event_binaries + .into_iter() + .map(|bytes| { + let hash: [u8; 32] = + ::digest(&bytes).into(); + (hash, bytes) + }) + .collect::>(); + let _ = definy_ui::indexed_db::store_events(&event_pairs).await; + } if !has_ssr_events { + if let Ok(cached_event_binaries) = + definy_ui::indexed_db::load_event_binaries().await + { + let mut cached_events = cached_event_binaries + .into_iter() + .map(|bytes| { + let hash: [u8; 32] = + ::digest(&bytes).into(); + let event = definy_event::verify_and_deserialize(&bytes); + (hash, event) + }) + .collect::>(); + cached_events.sort_by(|a, b| { + let a_time = match &a.1 { + Ok((_, event)) => event.time, + Err(_) => chrono::DateTime::::MIN_UTC, + }; + let b_time = match &b.1 { + Ok((_, event)) => event.time, + Err(_) => chrono::DateTime::::MIN_UTC, + }; + b_time.cmp(&a_time) + }); + fire(Box::new(move |state| { + let mut event_cache = state.event_cache.clone(); + let mut event_hashes = Vec::new(); + for (hash, event) in &cached_events { + event_cache.insert(*hash, event.clone()); + event_hashes.push(*hash); + } + AppState { + event_cache, + event_list_state: definy_ui::EventListState { + event_hashes, + current_offset: 0, + page_size: 20, + is_loading: true, + has_more: state.event_list_state.has_more, + filter_event_type: state.event_list_state.filter_event_type, + }, + ..state.clone() + } + })); + } let events = definy_ui::fetch::get_events( filter_for_fetch, Some(20), @@ -149,6 +202,23 @@ impl narumincho_vdom_client::App for DefinyApp { ..state.clone() })); } + let local_events = definy_ui::indexed_db::load_event_records().await; + fire(Box::new(move |state| { + let mut next = state.clone(); + match local_events { + Ok(records) => { + definy_ui::replace_local_event_records(&mut next, records); + next.local_event_queue.is_loading = false; + next.local_event_queue.last_error = None; + } + Err(error) => { + next.local_event_queue.is_loading = false; + next.local_event_queue.last_error = + Some(format!("Failed to load local events: {error:?}")); + } + } + next + })); }); let location = { @@ -164,7 +234,8 @@ impl narumincho_vdom_client::App for DefinyApp { definy_ui::Location::from_url(&pathname) }; - let (events, is_loading, has_more) = if let Some((ssr_events, has_more)) = ssr_state { + let (events, is_loading, has_more) = + if let Some((ssr_events, has_more, _)) = ssr_state { // SSRが送ってきた状態をそのまま採用 (ssr_events, false, has_more) } else { diff --git a/definy-ui/Cargo.toml b/definy-ui/Cargo.toml index 75902c13..99bf601c 100644 --- a/definy-ui/Cargo.toml +++ b/definy-ui/Cargo.toml @@ -26,6 +26,15 @@ web-sys = { version = "0.3.85", features = [ "HtmlTextAreaElement", "Navigator", "Clipboard", + "DomStringList", + "IdbDatabase", + "IdbFactory", + "IdbObjectStore", + "IdbOpenDbRequest", + "IdbRequest", + "IdbTransaction", + "IdbTransactionMode", + "IdbVersionChangeEvent", "Request", "RequestInit", "Response", diff --git a/definy-ui/main.css b/definy-ui/main.css index fca0b402..7461b773 100644 --- a/definy-ui/main.css +++ b/definy-ui/main.css @@ -1,35 +1,35 @@ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); :root { - --background: #0a1118; - --surface: rgba(16, 26, 38, 0.74); - --surface-hover: rgba(26, 39, 54, 0.9); - --surface-opaque: #121f2d; + --background: #10161b; + --surface: rgba(20, 28, 35, 0.88); + --surface-hover: rgba(28, 38, 46, 0.92); + --surface-opaque: #162129; - --primary-gradient: linear-gradient(135deg, #0ea5e9 0%, #22c55e 100%); - --primary-hover-gradient: linear-gradient(135deg, #38bdf8 0%, #4ade80 100%); - --primary: #38bdf8; - --primary-content: #f8fafc; + --primary-gradient: none; + --primary-hover-gradient: none; + --primary: #7cc0d8; + --primary-content: #f1f6fb; - --error: #f87171; - --error-bg: rgba(248, 113, 113, 0.14); + --error: #f28b82; + --error-bg: rgba(242, 139, 130, 0.12); - --border: rgba(167, 186, 210, 0.2); - --border-strong: rgba(167, 186, 210, 0.38); - --border-focus: rgba(56, 189, 248, 0.45); + --border: rgba(160, 176, 192, 0.18); + --border-strong: rgba(160, 176, 192, 0.32); + --border-focus: rgba(124, 192, 216, 0.4); - --text: #e6eef8; - --text-secondary: #a7bad2; + --text: #e1e8ef; + --text-secondary: #a3b2c2; --radius-sm: 8px; --radius-md: 12px; --radius-lg: 18px; --radius-full: 9999px; - --shadow-sm: 0 4px 12px rgba(7, 12, 20, 0.2); - --shadow-md: 0 12px 20px rgba(7, 12, 20, 0.3); - --shadow-lg: 0 20px 32px rgba(3, 8, 16, 0.4); - --shadow-glow: 0 0 0 3px rgba(56, 189, 248, 0.16); + --shadow-sm: 0 4px 10px rgba(7, 12, 18, 0.18); + --shadow-md: 0 10px 18px rgba(7, 12, 18, 0.26); + --shadow-lg: 0 18px 28px rgba(5, 10, 16, 0.34); + --shadow-glow: 0 0 0 3px rgba(124, 192, 216, 0.14); --glass-blur: blur(16px); --content-max-width: 920px; @@ -41,11 +41,7 @@ body { background-color: var(--background); - background-image: - radial-gradient(circle at 14% 8%, rgba(14, 165, 233, 0.14) 0%, transparent 42%), - radial-gradient(circle at 88% 78%, rgba(34, 197, 94, 0.14) 0%, transparent 40%), - linear-gradient(180deg, rgba(255, 255, 255, 0.01), rgba(255, 255, 255, 0)); - background-attachment: fixed; + background-image: none; color: var(--text); font-family: 'Outfit', system-ui, -apple-system, sans-serif; margin: 0; @@ -81,7 +77,7 @@ textarea { } button { - background: var(--primary-gradient); + background: var(--primary); color: var(--primary-content); border: 1px solid transparent; border-radius: var(--radius-full); @@ -98,7 +94,7 @@ button { } button:hover { - background: var(--primary-hover-gradient); + background: #8ad0e6; transform: translateY(-1px); box-shadow: 0 12px 22px rgba(14, 165, 233, 0.26); } diff --git a/definy-ui/src/account_detail.rs b/definy-ui/src/account_detail.rs index 84c7f91c..6d16458a 100644 --- a/definy-ui/src/account_detail.rs +++ b/definy-ui/src/account_detail.rs @@ -78,6 +78,7 @@ pub fn account_detail_view( return state; } let filter = state.event_list_state.filter_event_type; + let force_offline = state.force_offline; wasm_bindgen_futures::spawn_local(async move { let event_binary = match definy_event::sign_and_serialize( definy_event::event::Event { @@ -107,37 +108,61 @@ pub fn account_detail_view( } }; - if fetch::post_event(event_binary.as_slice()).await.is_ok() { - if let Ok(events) = - fetch::get_events(filter, Some(20), Some(0)).await - { - set_state_for_async(Box::new(move |state| { - let events_len = events.len(); - let mut event_cache = state.event_cache.clone(); - let mut event_hashes = Vec::new(); - for (hash, event) in events { - event_cache.insert(hash, event); - event_hashes.push(hash); - } - AppState { - event_cache, - event_list_state: crate::EventListState { - event_hashes, - current_offset: 0, - page_size: 20, - is_loading: false, - has_more: events_len == 20, - filter_event_type: filter, - }, - profile_name_input: String::new(), - ..state.clone() + match fetch::post_event_with_queue( + event_binary.as_slice(), + force_offline, + ) + .await + { + Ok(record) => { + let status = record.status.clone(); + if status == crate::local_event::LocalEventStatus::Sent { + if let Ok(events) = + fetch::get_events(filter, Some(20), Some(0)).await + { + set_state_for_async(Box::new(move |state| { + let events_len = events.len(); + let mut event_cache = state.event_cache.clone(); + let mut event_hashes = Vec::new(); + for (hash, event) in events { + event_cache.insert(hash, event); + event_hashes.push(hash); + } + let mut next = state.clone(); + next.event_cache = event_cache; + next.event_list_state = crate::EventListState { + event_hashes, + current_offset: 0, + page_size: 20, + is_loading: false, + has_more: events_len == 20, + filter_event_type: filter, + }; + next.profile_name_input = String::new(); + crate::app_state::upsert_local_event_record( + &mut next, + record, + ); + next + })); } - })); + } else { + set_state_for_async(Box::new(move |state| { + let mut next = state.clone(); + next.profile_name_input = String::new(); + crate::app_state::upsert_local_event_record( + &mut next, + record, + ); + next + })); + } + } + Err(_) => { + web_sys::console::log_1( + &"Failed to post change profile event".into(), + ); } - } else { - web_sys::console::log_1( - &"Failed to post change profile event".into(), - ); } }); state diff --git a/definy-ui/src/app_state.rs b/definy-ui/src/app_state.rs index ebabc4db..5cc24ace 100644 --- a/definy-ui/src/app_state.rs +++ b/definy-ui/src/app_state.rs @@ -99,6 +99,8 @@ pub struct AppState { pub event_detail_eval_result: Option, pub profile_name_input: String, pub is_header_popover_open: bool, + pub force_offline: bool, + pub local_event_queue: LocalEventQueueState, pub location: Option, pub focused_path: Option>, pub active_dropdown_name: Option, @@ -149,6 +151,13 @@ pub struct ModuleUpdateFormState { pub result_message: Option, } +#[derive(Clone)] +pub struct LocalEventQueueState { + pub items: Vec, + pub is_loading: bool, + pub last_error: Option, +} + impl AppState { pub fn account_name_map( &self, @@ -178,6 +187,26 @@ impl AppState { } } +pub fn upsert_local_event_record(state: &mut AppState, record: crate::local_event::LocalEventRecord) { + state + .local_event_queue + .items + .retain(|item| item.hash != record.hash); + state.local_event_queue.items.push(record); + state + .local_event_queue + .items + .sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms)); +} + +pub fn replace_local_event_records(state: &mut AppState, records: Vec) { + state.local_event_queue.items = records; + state + .local_event_queue + .items + .sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms)); +} + pub fn account_display_name( account_name_map: &std::collections::HashMap>, account_id: &definy_event::event::AccountId, @@ -215,6 +244,7 @@ pub fn build_initial_state( username: String::new(), generated_key: None, current_password: String::new(), + create_account_result_message: None, }, event_cache, event_list_state: EventListState { @@ -259,6 +289,12 @@ pub fn build_initial_state( event_detail_eval_result: None, profile_name_input: String::new(), is_header_popover_open: false, + force_offline: false, + local_event_queue: LocalEventQueueState { + items: Vec::new(), + is_loading: true, + last_error: None, + }, location, focused_path: None, active_dropdown_name: None, @@ -272,6 +308,8 @@ pub struct LoginOrCreateAccountDialogState { pub generated_key: Option, /// アカウント作成のユーザー名 pub username: String, + /// アカウント作成の送信結果メッセージ + pub create_account_result_message: Option, /// ログインまたはアカウント作成の状態 pub state: CreatingAccountState, @@ -295,6 +333,7 @@ pub enum Location { AccountList, PartList, ModuleList, + LocalEventQueue, Module([u8; 32]), Part([u8; 32]), Event([u8; 32]), @@ -308,6 +347,7 @@ impl narumincho_vdom::Route for Location { Location::AccountList => "/accounts".to_string(), Location::PartList => "/parts".to_string(), Location::ModuleList => "/modules".to_string(), + Location::LocalEventQueue => "/local-events".to_string(), Location::Module(hash) => format!( "/modules/{}", base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, hash) @@ -337,6 +377,7 @@ impl narumincho_vdom::Route for Location { ["accounts"] => Some(Location::AccountList), ["parts"] => Some(Location::PartList), ["modules"] => Some(Location::ModuleList), + ["local-events"] => Some(Location::LocalEventQueue), ["modules", hash_str] => decode_32bytes_base64(hash_str).map(Location::Module), ["parts", hash_str] => decode_32bytes_base64(hash_str).map(Location::Part), ["events", hash_str] => decode_32bytes_base64(hash_str).map(Location::Event), diff --git a/definy-ui/src/event_list.rs b/definy-ui/src/event_list.rs index f7b2f106..3a86369d 100644 --- a/definy-ui/src/event_list.rs +++ b/definy-ui/src/event_list.rs @@ -191,6 +191,7 @@ pub fn event_list_view(state: &AppState) -> Node { let expression = state.part_definition_form.composing_expression.clone(); let key_for_async = key.clone(); + let force_offline = state.force_offline; wasm_bindgen_futures::spawn_local(async move { let event_binary = definy_event::sign_and_serialize( @@ -214,31 +215,74 @@ pub fn event_list_view(state: &AppState) -> Node { ) .unwrap(); - let status = crate::fetch::post_event(event_binary.as_slice()).await; - match status { - Ok(_) => { - let events = crate::fetch::get_events(None, Some(20), Some(0)).await; - if let Ok(events) = events { - set_state_for_async(Box::new(|state| { - let events_len = events.len(); - let mut event_cache = state.event_cache.clone(); - let mut event_hashes = Vec::new(); - for (hash, event) in events { - event_cache.insert(hash, event); - event_hashes.push(hash); - } - AppState { - event_cache, - event_list_state: crate::EventListState { - event_hashes, - current_offset: 0, - page_size: 20, - is_loading: false, - has_more: events_len == 20, - filter_event_type: None, + match crate::fetch::post_event_with_queue( + event_binary.as_slice(), + force_offline, + ) + .await + { + Ok(record) => { + let status = record.status.clone(); + if status + == crate::local_event::LocalEventStatus::Sent + { + let events = crate::fetch::get_events( + None, + Some(20), + Some(0), + ) + .await; + if let Ok(events) = events { + set_state_for_async(Box::new( + move |state| { + let events_len = events.len(); + let mut event_cache = + state.event_cache.clone(); + let mut event_hashes = + Vec::new(); + for (hash, event) in events { + event_cache.insert(hash, event); + event_hashes.push(hash); + } + let mut next = state.clone(); + next.event_cache = event_cache; + next.event_list_state = + crate::EventListState { + event_hashes, + current_offset: 0, + page_size: 20, + is_loading: false, + has_more: events_len == 20, + filter_event_type: None, + }; + crate::app_state::upsert_local_event_record( + &mut next, + record, + ); + next }, - ..state.clone() - } + )); + } + } else { + set_state_for_async(Box::new(move |state| { + let mut next = state.clone(); + crate::app_state::upsert_local_event_record( + &mut next, + record, + ); + next.part_definition_form.eval_result = + Some(match status { + crate::local_event::LocalEventStatus::Queued => { + "PartDefinition queued (offline)".to_string() + } + crate::local_event::LocalEventStatus::Failed => { + "PartDefinition failed to send".to_string() + } + crate::local_event::LocalEventStatus::Sent => { + "PartDefinition posted".to_string() + } + }); + next })); } } diff --git a/definy-ui/src/fetch.rs b/definy-ui/src/fetch.rs index ae643995..871110b6 100644 --- a/definy-ui/src/fetch.rs +++ b/definy-ui/src/fetch.rs @@ -1,4 +1,5 @@ use sha2; +use wasm_bindgen::JsValue; pub async fn get_events_raw( event_type: Option, @@ -57,13 +58,22 @@ pub async fn get_events( let value = serde_cbor::from_slice::(&response_body_bytes)?; - Ok(value + let event_pairs = value .events .into_iter() .filter_map(|bytes| { let hash: [u8; 32] = ::digest(&bytes).into(); - Some((hash, definy_event::verify_and_deserialize(&bytes))) + Some((hash, bytes)) }) + .collect::)>>(); + + if let Err(error) = crate::indexed_db::store_events(&event_pairs).await { + web_sys::console::warn_1(&error); + } + + Ok(event_pairs + .into_iter() + .map(|(hash, bytes)| (hash, definy_event::verify_and_deserialize(&bytes))) .collect:: Result { .fetch_with_str_and_init("/events", &request_init), ) .await - .unwrap(); + .map_err(js_error_to_anyhow)?; - let response: web_sys::Response = wasm_bindgen::JsCast::dyn_into(response_raw).unwrap(); + let response: web_sys::Response = + wasm_bindgen::JsCast::dyn_into(response_raw).map_err(js_error_to_anyhow)?; Ok(response.status()) } + +pub async fn post_event_with_queue( + signated_event: &[u8], + force_offline: bool, +) -> Result { + let hash: [u8; 32] = ::digest(signated_event).into(); + let now_ms = chrono::Utc::now().timestamp_millis(); + + let (status, last_error) = if force_offline { + (crate::local_event::LocalEventStatus::Queued, None) + } else { + match post_event(signated_event).await { + Ok(status_code) if (200..300).contains(&(status_code as i32)) => { + (crate::local_event::LocalEventStatus::Sent, None) + } + Ok(status_code) => ( + crate::local_event::LocalEventStatus::Failed, + Some(format!("HTTP status {status_code}")), + ), + Err(error) => ( + crate::local_event::LocalEventStatus::Failed, + Some(format!("{error:?}")), + ), + } + }; + + let record = crate::local_event::LocalEventRecord { + hash, + event_binary: signated_event.to_vec(), + status, + updated_at_ms: now_ms, + last_error, + }; + + crate::indexed_db::store_event_record(&record) + .await + .map_err(js_error_to_anyhow)?; + + Ok(record) +} + +fn js_error_to_anyhow(value: JsValue) -> anyhow::Error { + if let Some(text) = value.as_string() { + anyhow::anyhow!(text) + } else { + anyhow::anyhow!("{value:?}") + } +} diff --git a/definy-ui/src/header.rs b/definy-ui/src/header.rs index 80dfd995..49285843 100644 --- a/definy-ui/src/header.rs +++ b/definy-ui/src/header.rs @@ -51,9 +51,7 @@ fn header_main(state: &AppState) -> Node { Style::new() .set("font-size", "1.48rem") .set("font-weight", "700") - .set("background", "var(--primary-gradient)") - .set("-webkit-background-clip", "text") - .set("-webkit-text-fill-color", "transparent") + .set("color", "var(--primary)") .set("letter-spacing", "-0.03em"), ) .children([text("definy")]) @@ -77,6 +75,15 @@ fn header_main(state: &AppState) -> Node { ) .children([text("Modules")]) .into_node(), + A::::new() + .href(Href::Internal(Location::LocalEventQueue)) + .style( + Style::new() + .set("font-size", "0.9rem") + .set("color", "var(--text-secondary)"), + ) + .children([text("Local Events")]) + .into_node(), A::::new() .href(Href::Internal(Location::AccountList)) .style( @@ -200,6 +207,30 @@ fn popover(state: &AppState) -> Node { if let Some(account_link) = account_link { children.push(account_link); } + children.push( + Button::new() + .on_click(EventHandler::new(async |set_state| { + set_state(Box::new(|state: AppState| -> AppState { + AppState { + force_offline: !state.force_offline, + ..state.clone() + } + })); + })) + .children([text(if state.force_offline { + "Offline: On" + } else { + "Offline: Off" + })]) + .style( + Style::new() + .set("width", "100%") + .set("background-color", "transparent") + .set("color", "var(--text)") + .set("justify-content", "flex-start"), + ) + .into_node(), + ); children.push( Button::new() .on_click(EventHandler::new(async |set_state| { diff --git a/definy-ui/src/indexed_db.rs b/definy-ui/src/indexed_db.rs new file mode 100644 index 00000000..dabd84e8 --- /dev/null +++ b/definy-ui/src/indexed_db.rs @@ -0,0 +1,200 @@ +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::JsFuture; + +const DB_NAME: &str = "definy"; +const DB_VERSION: u32 = 2; +const EVENTS_STORE: &str = "events"; + +pub async fn store_events(events: &[([u8; 32], Vec)]) -> Result<(), JsValue> { + if events.is_empty() { + return Ok(()); + } + + let db = open_db().await?; + let transaction = + db.transaction_with_str_and_mode(EVENTS_STORE, web_sys::IdbTransactionMode::Readwrite)?; + let store = transaction.object_store(EVENTS_STORE)?; + + for (hash, bytes) in events { + let key = JsValue::from_str(&crate::hash_format::encode_hash32(hash)); + let record = crate::local_event::LocalEventRecord { + hash: *hash, + event_binary: bytes.clone(), + status: crate::local_event::LocalEventStatus::Sent, + updated_at_ms: chrono::Utc::now().timestamp_millis(), + last_error: None, + }; + let value = serde_cbor::to_vec(&record) + .map_err(|error| JsValue::from_str(&format!("{error:?}")))?; + let value = js_sys::Uint8Array::from(value.as_slice()); + let request = store.put_with_key(&value, &key)?; + let _ = request_to_jsvalue(request).await?; + } + + Ok(()) +} + +pub async fn load_event_binaries() -> Result>, JsValue> { + let records = load_event_records().await?; + Ok(records.into_iter().map(|record| record.event_binary).collect()) +} + +pub async fn store_event_record( + record: &crate::local_event::LocalEventRecord, +) -> Result<(), JsValue> { + let db = open_db().await?; + let transaction = + db.transaction_with_str_and_mode(EVENTS_STORE, web_sys::IdbTransactionMode::Readwrite)?; + let store = transaction.object_store(EVENTS_STORE)?; + + let key = JsValue::from_str(&crate::hash_format::encode_hash32(&record.hash)); + let value = serde_cbor::to_vec(record).map_err(|error| JsValue::from_str(&format!("{error:?}")))?; + let value = js_sys::Uint8Array::from(value.as_slice()); + let request = store.put_with_key(&value, &key)?; + let _ = request_to_jsvalue(request).await?; + Ok(()) +} + +pub async fn remove_event_record(hash: &[u8; 32]) -> Result<(), JsValue> { + let db = open_db().await?; + let transaction = + db.transaction_with_str_and_mode(EVENTS_STORE, web_sys::IdbTransactionMode::Readwrite)?; + let store = transaction.object_store(EVENTS_STORE)?; + let key = JsValue::from_str(&crate::hash_format::encode_hash32(hash)); + let request = store.delete(&key)?; + let _ = request_to_jsvalue(request).await?; + Ok(()) +} + +pub async fn load_event_records() -> Result, JsValue> { + let db = open_db().await?; + let transaction = db.transaction_with_str(EVENTS_STORE)?; + let store = transaction.object_store(EVENTS_STORE)?; + let request = store.get_all()?; + let value = request_to_jsvalue(request).await?; + let array = js_sys::Array::from(&value); + let mut records = Vec::new(); + for value in array.iter() { + let bytes = js_sys::Uint8Array::new(&value).to_vec(); + if let Ok(record) = + serde_cbor::from_slice::(&bytes) + { + records.push(record); + } else { + let hash: [u8; 32] = ::digest(&bytes).into(); + records.push(crate::local_event::LocalEventRecord { + hash, + event_binary: bytes, + status: crate::local_event::LocalEventStatus::Sent, + updated_at_ms: chrono::Utc::now().timestamp_millis(), + last_error: None, + }); + } + } + Ok(records) +} + +async fn open_db() -> Result { + let factory = web_sys::window() + .ok_or_else(|| JsValue::from_str("missing window"))? + .indexed_db()? + .ok_or_else(|| JsValue::from_str("indexedDB not available"))?; + + let request = factory.open_with_u32(DB_NAME, DB_VERSION)?; + + let upgrade_request = request.clone(); + let on_upgrade = Closure::wrap(Box::new(move |_event: web_sys::IdbVersionChangeEvent| { + if let Ok(result) = upgrade_request.result() { + if let Ok(db) = result.dyn_into::() { + let store_names = db.object_store_names(); + let mut has_events = false; + for index in 0..store_names.length() { + if let Some(name) = store_names.get(index) { + if name == EVENTS_STORE { + has_events = true; + } + } + } + if !has_events { + let _ = db.create_object_store(EVENTS_STORE); + } + } + } + }) as Box); + request.set_onupgradeneeded(Some(on_upgrade.as_ref().unchecked_ref())); + on_upgrade.forget(); + + let success_request = request.clone(); + let error_request = request.clone(); + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let request_for_handler = success_request.clone(); + let success_request = success_request.clone(); + let reject_for_success = reject.clone(); + let on_success = Closure::once(Box::new(move |_event: web_sys::Event| { + match success_request.result() { + Ok(result) => match result.dyn_into::() { + Ok(db) => { + let _ = resolve.call1(&JsValue::NULL, &db); + } + Err(error) => { + let _ = reject_for_success.call1(&JsValue::NULL, &error); + } + }, + Err(error) => { + let _ = reject_for_success.call1(&JsValue::NULL, &error); + } + } + }) as Box); + request_for_handler.set_onsuccess(Some(on_success.as_ref().unchecked_ref())); + on_success.forget(); + + let reject_for_error = reject.clone(); + let on_error = Closure::once(Box::new(move |_event: web_sys::Event| { + let _ = reject_for_error.call1( + &JsValue::NULL, + &JsValue::from_str("indexedDB open failed"), + ); + }) as Box); + error_request.set_onerror(Some(on_error.as_ref().unchecked_ref())); + on_error.forget(); + }); + + let db = JsFuture::from(promise).await?; + db.dyn_into::() +} + +async fn request_to_jsvalue(request: web_sys::IdbRequest) -> Result { + let success_request = request.clone(); + let error_request = request.clone(); + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let request_for_handler = success_request.clone(); + let success_request = success_request.clone(); + let reject_for_success = reject.clone(); + let on_success = Closure::once(Box::new(move |_event: web_sys::Event| { + match success_request.result() { + Ok(result) => { + let _ = resolve.call1(&JsValue::NULL, &result); + } + Err(error) => { + let _ = reject_for_success.call1(&JsValue::NULL, &error); + } + } + }) as Box); + request_for_handler.set_onsuccess(Some(on_success.as_ref().unchecked_ref())); + on_success.forget(); + + let reject_for_error = reject.clone(); + let on_error = Closure::once(Box::new(move |_event: web_sys::Event| { + let _ = reject_for_error.call1( + &JsValue::NULL, + &JsValue::from_str("indexedDB request failed"), + ); + }) as Box); + error_request.set_onerror(Some(on_error.as_ref().unchecked_ref())); + on_error.forget(); + }); + + JsFuture::from(promise).await +} diff --git a/definy-ui/src/lib.rs b/definy-ui/src/lib.rs index 068170b7..a3dd0358 100644 --- a/definy-ui/src/lib.rs +++ b/definy-ui/src/lib.rs @@ -11,7 +11,10 @@ mod expression_eval; pub mod fetch; mod hash_format; mod header; +pub mod indexed_db; mod layout; +mod local_event; +mod local_event_queue; mod login_or_create_account_dialog; mod message; mod module_detail; @@ -28,6 +31,7 @@ pub mod wasm_emitter; pub use app_state::*; pub use event_filter::*; pub use message::Message; +pub use local_event::*; use narumincho_vdom::*; @@ -145,6 +149,9 @@ init({{ module_or_path: \"/{}\" }});", Some(Location::AccountList) => account_list::account_list_view(state), Some(Location::PartList) => part_list::part_list_view(state), Some(Location::ModuleList) => module_list::module_list_view(state), + Some(Location::LocalEventQueue) => { + local_event_queue::local_event_queue_view(state) + } Some(Location::Module(hash)) => { module_detail::module_detail_view(state, hash) } diff --git a/definy-ui/src/local_event.rs b/definy-ui/src/local_event.rs new file mode 100644 index 00000000..396ee1d3 --- /dev/null +++ b/definy-ui/src/local_event.rs @@ -0,0 +1,15 @@ +#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub enum LocalEventStatus { + Queued, + Sent, + Failed, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct LocalEventRecord { + pub hash: [u8; 32], + pub event_binary: Vec, + pub status: LocalEventStatus, + pub updated_at_ms: i64, + pub last_error: Option, +} diff --git a/definy-ui/src/local_event_queue.rs b/definy-ui/src/local_event_queue.rs new file mode 100644 index 00000000..4ac87f88 --- /dev/null +++ b/definy-ui/src/local_event_queue.rs @@ -0,0 +1,303 @@ +use narumincho_vdom::*; + +use crate::app_state::{replace_local_event_records, AppState}; +use crate::local_event::LocalEventStatus; + +fn status_label(status: &LocalEventStatus) -> &'static str { + match status { + LocalEventStatus::Queued => "送信待ち", + LocalEventStatus::Sent => "送信済み", + LocalEventStatus::Failed => "送信失敗", + } +} + +fn status_color(status: &LocalEventStatus) -> &'static str { + match status { + LocalEventStatus::Queued => "#fbbf24", + LocalEventStatus::Sent => "#34d399", + LocalEventStatus::Failed => "#f87171", + } +} + +fn format_time_ms(time_ms: i64) -> String { + chrono::DateTime::::from_timestamp_millis(time_ms) + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +pub fn local_event_queue_view(state: &AppState) -> Node { + let refresh_button = Button::new() + .on_click(EventHandler::new(async |set_state| { + let set_state = std::rc::Rc::new(set_state); + let set_state_for_async = set_state.clone(); + set_state(Box::new(|state: AppState| { + let mut next = state.clone(); + next.local_event_queue.is_loading = true; + next + })); + let result = crate::indexed_db::load_event_records().await; + set_state_for_async(Box::new(move |state: AppState| { + let mut next = state.clone(); + match result { + Ok(records) => { + replace_local_event_records(&mut next, records); + next.local_event_queue.is_loading = false; + next.local_event_queue.last_error = None; + } + Err(error) => { + next.local_event_queue.is_loading = false; + next.local_event_queue.last_error = + Some(format!("Failed to load local events: {error:?}")); + } + } + next + })); + })) + .style( + Style::new() + .set("background", "rgba(255, 255, 255, 0.08)") + .set("border", "1px solid var(--border)") + .set("color", "var(--text)") + .set("padding", "0.4rem 0.8rem") + .set("border-radius", "0.5rem"), + ) + .children([text("Refresh")]) + .into_node(); + + let offline_toggle = Button::new() + .on_click(EventHandler::new(async |set_state| { + set_state(Box::new(|state: AppState| { + let mut next = state.clone(); + next.force_offline = !next.force_offline; + next + })); + })) + .style( + Style::new() + .set("background", "rgba(255, 255, 255, 0.08)") + .set("border", "1px solid var(--border)") + .set("color", "var(--text)") + .set("padding", "0.4rem 0.8rem") + .set("border-radius", "0.5rem"), + ) + .children([text(if state.force_offline { + "Offline: On" + } else { + "Offline: Off" + })]) + .into_node(); + + let mut list_items = Vec::new(); + if state.local_event_queue.items.is_empty() { + list_items.push( + Div::new() + .style(Style::new().set("color", "var(--text-secondary)")) + .children([text("No local events")]) + .into_node(), + ); + } else { + for record in &state.local_event_queue.items { + let status = record.status.clone(); + let hash = record.hash; + let status_badge = Div::new() + .style( + Style::new() + .set("background", status_color(&status)) + .set("color", "#0b0f19") + .set("padding", "0.12rem 0.5rem") + .set("border-radius", "999px") + .set("font-size", "0.75rem") + .set("font-weight", "600") + .set("display", "inline-flex"), + ) + .children([text(status_label(&status))]) + .into_node(); + + let summary = match definy_event::verify_and_deserialize(&record.event_binary) { + Ok((_, event)) => crate::event_presenter::event_summary_text(&event), + Err(_) => "Invalid event".to_string(), + }; + + let mut actions = Vec::new(); + if status != LocalEventStatus::Sent { + let hash = hash; + actions.push( + Button::new() + .on_click(EventHandler::new(move |set_state| { + let hash = hash; + async move { + let result = crate::indexed_db::remove_event_record(&hash).await; + set_state(Box::new(move |state: AppState| { + let mut next = state.clone(); + match result { + Ok(()) => { + next.local_event_queue + .items + .retain(|item| item.hash != hash); + } + Err(error) => { + next.local_event_queue.last_error = Some(format!( + "Failed to cancel queued event: {error:?}" + )); + } + } + next + })); + } + })) + .style( + Style::new() + .set("background", "transparent") + .set("border", "1px solid var(--border)") + .set("color", "var(--text)") + .set("padding", "0.3rem 0.6rem") + .set("border-radius", "0.45rem"), + ) + .children([text("Cancel")]) + .into_node(), + ); + } + + let error_note = record + .last_error + .as_ref() + .map(|error| { + Div::new() + .style( + Style::new() + .set("color", "#fca5a5") + .set("font-size", "0.78rem") + .set("word-break", "break-word"), + ) + .children([text(error)]) + .into_node() + }) + .unwrap_or_else(|| Div::new().children([]).into_node()); + + list_items.push( + Div::new() + .class("event-card") + .style( + Style::new() + .set("display", "grid") + .set("gap", "0.4rem"), + ) + .children([ + Div::new() + .style( + Style::new() + .set("display", "flex") + .set("justify-content", "space-between") + .set("align-items", "center") + .set("gap", "0.5rem"), + ) + .children([ + status_badge, + Div::new() + .style( + Style::new() + .set("color", "var(--text-secondary)") + .set("font-size", "0.78rem") + .set("font-family", "'JetBrains Mono', monospace") + .set("display", "inline-flex"), + ) + .children([text(crate::hash_format::short_hash32(&hash))]) + .into_node(), + ]) + .into_node(), + Div::new() + .style( + Style::new() + .set("font-weight", "600") + .set("font-size", "0.92rem"), + ) + .children([text(summary)]) + .into_node(), + Div::new() + .style( + Style::new() + .set("color", "var(--text-secondary)") + .set("font-size", "0.78rem"), + ) + .children([text(format_time_ms(record.updated_at_ms))]) + .into_node(), + error_note, + if actions.is_empty() { + Div::new().children([]).into_node() + } else { + Div::new() + .style(Style::new().set("display", "flex").set("gap", "0.4rem")) + .children(actions) + .into_node() + }, + ]) + .into_node(), + ); + } + } + + Div::new() + .class("page-shell") + .style(crate::layout::page_shell_style("1rem")) + .children([ + Div::new() + .style( + Style::new() + .set("display", "flex") + .set("justify-content", "space-between") + .set("align-items", "center") + .set("gap", "0.8rem"), + ) + .children([ + Div::new() + .style(Style::new().set("display", "grid").set("gap", "0.2rem")) + .children([ + H2::new().children([text("Local Events")]).into_node(), + Div::new() + .style( + Style::new() + .set("color", "var(--text-secondary)") + .set("font-size", "0.82rem") + .set("display", "inline-flex"), + ) + .children([text( + "indexedDB に保存された送信履歴・送信待ちイベント", + )]) + .into_node(), + ]) + .into_node(), + Div::new() + .style(Style::new().set("display", "flex").set("gap", "0.5rem")) + .children([refresh_button, offline_toggle]) + .into_node(), + ]) + .into_node(), + if state.local_event_queue.is_loading { + Div::new() + .style( + Style::new() + .set("color", "var(--text-secondary)") + .set("font-size", "0.82rem"), + ) + .children([text("Loading...")]) + .into_node() + } else if let Some(error) = &state.local_event_queue.last_error { + Div::new() + .style( + Style::new() + .set("color", "#fca5a5") + .set("font-size", "0.84rem"), + ) + .children([text(error)]) + .into_node() + } else { + Div::new().children([]).into_node() + }, + Div::new() + .class("event-list") + .style(Style::new().set("display", "grid").set("gap", "0.6rem")) + .children(list_items) + .into_node(), + ]) + .into_node() +} diff --git a/definy-ui/src/login_or_create_account_dialog.rs b/definy-ui/src/login_or_create_account_dialog.rs index ed9062ba..25e2053c 100644 --- a/definy-ui/src/login_or_create_account_dialog.rs +++ b/definy-ui/src/login_or_create_account_dialog.rs @@ -147,6 +147,7 @@ pub fn login_or_create_account_dialog(state: &AppState) -> Node { state: CreatingAccountState::CreateAccount, username: String::new(), current_password: String::new(), + create_account_result_message: None, }, ..state.clone() } @@ -159,7 +160,10 @@ pub fn login_or_create_account_dialog(state: &AppState) -> Node { match state.login_or_create_account_dialog_state.state { CreatingAccountState::LogIn => login_view(), CreatingAccountState::CreateAccount => { - create_account_view(&state.login_or_create_account_dialog_state) + create_account_view( + &state.login_or_create_account_dialog_state, + state.force_offline, + ) } _ => Div::new().children([]).into_node(), }, @@ -224,7 +228,10 @@ fn generate_key() -> ed25519_dalek::SigningKey { ed25519_dalek::SigningKey::generate(&mut csprng) } -fn create_account_view(state: &LoginOrCreateAccountDialogState) -> Node { +fn create_account_view( + state: &LoginOrCreateAccountDialogState, + force_offline: bool, +) -> Node { let mut password_input = Input::new() .type_("password") .name("password") @@ -247,6 +254,8 @@ fn create_account_view(state: &LoginOrCreateAccountDialogState) -> Node( @@ -280,10 +289,48 @@ fn create_account_view(state: &LoginOrCreateAccountDialogState) -> Node { + "Account created".to_string() + } + crate::local_event::LocalEventStatus::Queued => { + "Queued: network unavailable".to_string() + } + crate::local_event::LocalEventStatus::Failed => { + record + .last_error + .clone() + .unwrap_or_else(|| "Failed to send".to_string()) + } + }; + set_state_for_async(Box::new(move |state: AppState| { + let mut next = state.clone(); + crate::app_state::upsert_local_event_record(&mut next, record); + next.login_or_create_account_dialog_state.state = + match status_for_state { + crate::local_event::LocalEventStatus::Sent => { + CreatingAccountState::Success + } + crate::local_event::LocalEventStatus::Queued => { + CreatingAccountState::Error + } + crate::local_event::LocalEventStatus::Failed => { + CreatingAccountState::Error + } + }; + next.login_or_create_account_dialog_state + .create_account_result_message = Some(message); + next + })); + if status == crate::local_event::LocalEventStatus::Sent { + dialog_close(); + } } }); } @@ -292,6 +339,7 @@ fn create_account_view(state: &LoginOrCreateAccountDialogState) -> Node Node Div::new() + .style( + Style::new() + .set("font-size", "0.82rem") + .set("color", "var(--text-secondary)"), + ) + .children([text(message)]) + .into_node(), + None => Div::new().children([]).into_node(), + }, ]) .into_node() } @@ -437,6 +496,7 @@ fn create_login_event_handler() -> EventHandler { state: CreatingAccountState::LogIn, username: String::new(), current_password: String::new(), + create_account_result_message: None, }, ..state.clone() } diff --git a/definy-ui/src/module_detail.rs b/definy-ui/src/module_detail.rs index 5d9f85a9..bf1fe67a 100644 --- a/definy-ui/src/module_detail.rs +++ b/definy-ui/src/module_detail.rs @@ -319,6 +319,7 @@ fn module_update_form( return next; } let module_description = module_description; + let force_offline = state.force_offline; wasm_bindgen_futures::spawn_local(async move { let event_binary = match definy_event::sign_and_serialize( definy_event::event::Event { @@ -351,49 +352,86 @@ fn module_update_form( } }; - match crate::fetch::post_event(event_binary.as_slice()).await { - Ok(_) => { - if let Ok(events) = - crate::fetch::get_events(None, Some(20), Some(0)).await - { + match crate::fetch::post_event_with_queue( + event_binary.as_slice(), + force_offline, + ) + .await + { + Ok(record) => { + let status = record.status.clone(); + if status == crate::local_event::LocalEventStatus::Sent { + if let Ok(events) = + crate::fetch::get_events(None, Some(20), Some(0)).await + { + set_state_for_async(Box::new(move |state| { + let events_len = events.len(); + let mut event_cache = state.event_cache.clone(); + let mut event_hashes = Vec::new(); + for (hash, event) in events { + event_cache.insert(hash, event); + event_hashes.push(hash); + } + let mut next = state.clone(); + next.event_cache = event_cache; + next.event_list_state = crate::EventListState { + event_hashes, + current_offset: 0, + page_size: 20, + is_loading: false, + has_more: events_len == 20, + filter_event_type: None, + }; + crate::app_state::upsert_local_event_record( + &mut next, + record, + ); + if let Some(snapshot) = find_module_snapshot( + &next, + &root_module_definition_hash, + ) { + next.module_update_form + .module_definition_event_hash = + Some(root_module_definition_hash); + next.module_update_form.module_name_input = + snapshot.module_name; + next.module_update_form + .module_description_input = + snapshot.module_description; + } else { + next.module_update_form + .module_definition_event_hash = None; + next.module_update_form.module_name_input = + String::new(); + next.module_update_form + .module_description_input = + String::new(); + } + next.module_update_form.result_message = + Some("ModuleUpdate event posted".to_string()); + next + })); + } + } else { set_state_for_async(Box::new(move |state| { - let events_len = events.len(); - let mut event_cache = state.event_cache.clone(); - let mut event_hashes = Vec::new(); - for (hash, event) in events { - event_cache.insert(hash, event); - event_hashes.push(hash); - } let mut next = state.clone(); - next.event_cache = event_cache; - next.event_list_state = crate::EventListState { - event_hashes, - current_offset: 0, - page_size: 20, - is_loading: false, - has_more: events_len == 20, - filter_event_type: None, - }; - if let Some(snapshot) = - find_module_snapshot(&next, &root_module_definition_hash) - { - next.module_update_form - .module_definition_event_hash = - Some(root_module_definition_hash); - next.module_update_form.module_name_input = - snapshot.module_name; - next.module_update_form.module_description_input = - snapshot.module_description; - } else { - next.module_update_form - .module_definition_event_hash = None; - next.module_update_form.module_name_input = - String::new(); - next.module_update_form.module_description_input = - String::new(); - } - next.module_update_form.result_message = - Some("ModuleUpdate event posted".to_string()); + crate::app_state::upsert_local_event_record( + &mut next, + record, + ); + next.module_update_form.result_message = Some( + match status { + crate::local_event::LocalEventStatus::Queued => { + "ModuleUpdate queued (offline)".to_string() + } + crate::local_event::LocalEventStatus::Failed => { + "ModuleUpdate failed to send".to_string() + } + crate::local_event::LocalEventStatus::Sent => { + "ModuleUpdate event posted".to_string() + } + }, + ); next })); } diff --git a/definy-ui/src/module_list.rs b/definy-ui/src/module_list.rs index b0cd77ed..bec8d911 100644 --- a/definy-ui/src/module_list.rs +++ b/definy-ui/src/module_list.rs @@ -221,6 +221,7 @@ fn module_create_form(state: &AppState) -> Node { return next; } let key_for_async = key.clone(); + let force_offline = state.force_offline; wasm_bindgen_futures::spawn_local(async move { let event_binary = definy_event::sign_and_serialize( @@ -240,18 +241,46 @@ fn module_create_form(state: &AppState) -> Node { ) .unwrap(); - let status = crate::fetch::post_event(event_binary.as_slice()).await; - match status { - Ok(_) => { - let hash: [u8; 32] = - ::digest(&event_binary).into(); - let event = definy_event::verify_and_deserialize( - event_binary.as_slice(), - ); + match crate::fetch::post_event_with_queue( + event_binary.as_slice(), + force_offline, + ) + .await + { + Ok(record) => { + let status = record.status.clone(); set_state_for_async(Box::new(move |state| { let mut next = state.clone(); - next.event_cache.insert(hash, event); - next.module_definition_form.result_message = None; + crate::app_state::upsert_local_event_record( + &mut next, + record, + ); + if status == crate::local_event::LocalEventStatus::Sent { + let hash: [u8; 32] = + ::digest(&event_binary) + .into(); + let event = definy_event::verify_and_deserialize( + event_binary.as_slice(), + ); + next.event_cache.insert(hash, event); + next.module_definition_form.result_message = None; + } else { + next.module_definition_form.result_message = Some( + match status { + crate::local_event::LocalEventStatus::Queued => { + "ModuleDefinition queued (offline)" + .to_string() + } + crate::local_event::LocalEventStatus::Failed => { + "ModuleDefinition failed to send" + .to_string() + } + crate::local_event::LocalEventStatus::Sent => { + "ModuleDefinition posted".to_string() + } + }, + ); + } next })); } diff --git a/definy-ui/src/page_title.rs b/definy-ui/src/page_title.rs index d1678b4b..a369ef5e 100644 --- a/definy-ui/src/page_title.rs +++ b/definy-ui/src/page_title.rs @@ -6,6 +6,7 @@ enum RouteId { AccountList, PartList, ModuleList, + LocalEventQueue, AccountDetail, PartDetail, ModuleDetail, @@ -20,6 +21,7 @@ impl RouteId { Some(Location::AccountList) => Self::AccountList, Some(Location::PartList) => Self::PartList, Some(Location::ModuleList) => Self::ModuleList, + Some(Location::LocalEventQueue) => Self::LocalEventQueue, Some(Location::Account(_)) => Self::AccountDetail, Some(Location::Part(_)) => Self::PartDetail, Some(Location::Module(_)) => Self::ModuleDetail, @@ -34,6 +36,7 @@ impl RouteId { Self::AccountList => "accounts", Self::PartList => "parts", Self::ModuleList => "modules", + Self::LocalEventQueue => "local-events", Self::AccountDetail => "accounts", Self::PartDetail => "parts", Self::ModuleDetail => "modules", @@ -50,6 +53,7 @@ pub fn page_title_text(state: &AppState) -> String { | Some(Location::AccountList) | Some(Location::PartList) | Some(Location::ModuleList) + | Some(Location::LocalEventQueue) | None => { route_id.title_prefix().to_string() } diff --git a/definy-ui/src/part_detail.rs b/definy-ui/src/part_detail.rs index 4035f598..0de1ea12 100644 --- a/definy-ui/src/part_detail.rs +++ b/definy-ui/src/part_detail.rs @@ -355,6 +355,7 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< let part_description = current_part_description; let expression = current_expression; let module_definition_event_hash = current_module_hash; + let force_offline = state.force_offline; wasm_bindgen_futures::spawn_local(async move { let event_binary = match definy_event::sign_and_serialize( definy_event::event::Event { @@ -388,60 +389,97 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< } }; - match crate::fetch::post_event(event_binary.as_slice()).await { - Ok(_) => { - if let Ok(events) = crate::fetch::get_events(None, Some(20), Some(0)).await { + match crate::fetch::post_event_with_queue( + event_binary.as_slice(), + force_offline, + ) + .await + { + Ok(record) => { + let status = record.status.clone(); + if status == crate::local_event::LocalEventStatus::Sent { + if let Ok(events) = + crate::fetch::get_events(None, Some(20), Some(0)).await + { + set_state_for_async(Box::new(move |state| { + let events_len = events.len(); + let mut event_cache = state.event_cache.clone(); + let mut event_hashes = Vec::new(); + for (hash, event) in events { + event_cache.insert(hash, event); + event_hashes.push(hash); + } + let mut next = state.clone(); + next.event_cache = event_cache; + next.event_list_state = crate::EventListState { + event_hashes, + current_offset: 0, + page_size: 20, + is_loading: false, + has_more: events_len == 20, + filter_event_type: None, + }; + crate::app_state::upsert_local_event_record( + &mut next, + record, + ); + if let Some(snapshot) = find_part_snapshot( + &next, + &root_part_definition_hash, + ) { + next.part_update_form + .part_definition_event_hash = + Some(root_part_definition_hash); + next.part_update_form.part_name_input = + snapshot.part_name; + next.part_update_form + .part_description_input = + snapshot.part_description; + next.part_update_form.expression_input = + snapshot.expression; + next.part_update_form + .module_definition_event_hash = + snapshot.module_definition_event_hash; + } else { + next.part_update_form + .part_definition_event_hash = None; + next.part_update_form.part_name_input = + String::new(); + next.part_update_form + .part_description_input = + String::new(); + next.part_update_form.expression_input = + definy_event::event::Expression::Number( + definy_event::event::NumberExpression { + value: 0, + }, + ); + next.part_update_form + .module_definition_event_hash = None; + } + next.event_detail_eval_result = + Some("PartUpdate event posted".to_string()); + next + })); + } + } else { set_state_for_async(Box::new(move |state| { - let events_len = events.len(); - let mut event_cache = state.event_cache.clone(); - let mut event_hashes = Vec::new(); - for (hash, event) in events { - event_cache.insert(hash, event); - event_hashes.push(hash); - } let mut next = state.clone(); - next.event_cache = event_cache; - next.event_list_state = crate::EventListState { - event_hashes, - current_offset: 0, - page_size: 20, - is_loading: false, - has_more: events_len == 20, - filter_event_type: None, - }; - if let Some(snapshot) = find_part_snapshot( - &next, - &root_part_definition_hash, - ) { - next.part_update_form - .part_definition_event_hash = - Some(root_part_definition_hash); - next.part_update_form.part_name_input = - snapshot.part_name; - next.part_update_form.part_description_input = - snapshot.part_description; - next.part_update_form.expression_input = - snapshot.expression; - next.part_update_form.module_definition_event_hash = - snapshot.module_definition_event_hash; - } else { - next.part_update_form - .part_definition_event_hash = None; - next.part_update_form.part_name_input = - String::new(); - next.part_update_form.part_description_input = - String::new(); - next.part_update_form.expression_input = - definy_event::event::Expression::Number( - definy_event::event::NumberExpression { - value: 0, - }, - ); - next.part_update_form.module_definition_event_hash = - None; - } - next.event_detail_eval_result = - Some("PartUpdate event posted".to_string()); + crate::app_state::upsert_local_event_record( + &mut next, + record, + ); + next.event_detail_eval_result = Some(match status { + crate::local_event::LocalEventStatus::Queued => { + "PartUpdate queued (offline)".to_string() + } + crate::local_event::LocalEventStatus::Failed => { + "PartUpdate failed to send".to_string() + } + crate::local_event::LocalEventStatus::Sent => { + "PartUpdate event posted".to_string() + } + }); next })); } diff --git a/definy-ui/tests/browser_e2e.rs b/definy-ui/tests/browser_e2e.rs index 4b14f724..70bc72dd 100644 --- a/definy-ui/tests/browser_e2e.rs +++ b/definy-ui/tests/browser_e2e.rs @@ -478,6 +478,7 @@ fn render_html_response(path: &str) -> Response> { state: definy_ui::CreatingAccountState::LogIn, username: String::new(), current_password: String::new(), + create_account_result_message: None, }, event_cache: std::collections::HashMap::new(), event_list_state: definy_ui::EventListState { @@ -522,6 +523,12 @@ fn render_html_response(path: &str) -> Response> { event_detail_eval_result: None, profile_name_input: String::new(), is_header_popover_open: false, + force_offline: false, + local_event_queue: definy_ui::LocalEventQueueState { + items: Vec::new(), + is_loading: false, + last_error: None, + }, location, }, &Some(definy_ui::ResourceHash { diff --git a/narumincho-vdom-client/Cargo.toml b/narumincho-vdom-client/Cargo.toml index 2dd78d69..93ede3b8 100644 --- a/narumincho-vdom-client/Cargo.toml +++ b/narumincho-vdom-client/Cargo.toml @@ -3,6 +3,9 @@ name = "narumincho-vdom-client" version = "0.1.0" edition = "2024" +[lib] +doctest = false + [dependencies] narumincho-vdom = { path = "../narumincho-vdom" } js-sys = "0.3" diff --git a/narumincho-vdom-client/src/diff.rs b/narumincho-vdom-client/src/diff.rs index cc20fab4..1aed6b6e 100644 --- a/narumincho-vdom-client/src/diff.rs +++ b/narumincho-vdom-client/src/diff.rs @@ -123,16 +123,76 @@ fn diff_recursive( patches.push((path.clone(), Patch::AddEventListeners(add_events))); } - // Diff children + // Diff children (key-aware) let common_len = std::cmp::min(old_element.children.len(), new_element.children.len()); + let old_keys = old_element + .children + .iter() + .map(child_key) + .collect::>>(); + let new_keys = new_element + .children + .iter() + .map(child_key) + .collect::>>(); + let has_keys = old_keys.iter().any(|k| k.is_some()) || new_keys.iter().any(|k| k.is_some()); + + if has_keys { + let all_old_keyed = old_keys.iter().all(|k| k.is_some()); + let all_new_keyed = new_keys.iter().all(|k| k.is_some()); + if all_old_keyed && all_new_keyed { + let old_key_list = old_keys + .iter() + .map(|k| k.as_ref().unwrap().clone()) + .collect::>(); + let new_key_list = new_keys + .iter() + .map(|k| k.as_ref().unwrap().clone()) + .collect::>(); + if old_key_list != new_key_list { + if old_element.children.len() > 0 { + patches.push(( + path.clone(), + Patch::RemoveChildren(old_element.children.len()), + )); + } + if !new_element.children.is_empty() { + patches.push(( + path.clone(), + Patch::AppendChildren(new_element.children.clone()), + )); + } + return; + } + } + } + for i in 0..common_len { path.push(i); - diff_recursive( - &old_element.children[i], - &new_element.children[i], - path, - patches, - ); + let old_key = old_keys.get(i).and_then(|k| k.clone()); + let new_key = new_keys.get(i).and_then(|k| k.clone()); + if has_keys && (old_key.is_some() || new_key.is_some()) { + if old_key.is_some() && new_key.is_some() && old_key == new_key { + diff_recursive( + &old_element.children[i], + &new_element.children[i], + path, + patches, + ); + } else { + patches.push(( + path.clone(), + Patch::Replace(new_element.children[i].clone()), + )); + } + } else { + diff_recursive( + &old_element.children[i], + &new_element.children[i], + path, + patches, + ); + } path.pop(); } @@ -161,6 +221,17 @@ fn diff_recursive( } } +fn child_key(node: &Node) -> Option { + match node { + Node::Element(element) => element + .attributes + .iter() + .find(|(key, _)| key == "key") + .map(|(_, value)| value.clone()), + Node::Text(_) => None, + } +} + pub fn add_event_listener_patches(node: &Node) -> Vec<(Vec, Patch)> { let mut patches = Vec::new(); add_event_listener_patches_recursive(node, &mut Vec::new(), &mut patches); diff --git a/narumincho-vdom/Cargo.toml b/narumincho-vdom/Cargo.toml index f8cfc354..1a25a58f 100644 --- a/narumincho-vdom/Cargo.toml +++ b/narumincho-vdom/Cargo.toml @@ -3,5 +3,8 @@ name = "narumincho-vdom" version = "0.1.0" edition = "2024" +[lib] +doctest = false + [dependencies] sha2 = "0.10.9"