From 5e1448171444fc01deca7fb2c16662fb469e7030aef543136db8d6965e5b80c6 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sat, 24 Jan 2026 00:18:54 -0600 Subject: [PATCH] fix: inventory interface and hot keys --- crates/chattyness-db/src/models.rs | 3 + crates/chattyness-db/src/queries/inventory.rs | 3 + .../src/components/inventory.rs | 240 ++++++++++++++++-- 3 files changed, 221 insertions(+), 25 deletions(-) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 3c0f37c..f4eed0e 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -827,12 +827,15 @@ impl std::fmt::Display for PropOrigin { #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct InventoryItem { pub id: Uuid, + /// The source prop ID (use `origin` to determine which table: Server or Realm) + pub prop_id: Option, pub prop_name: String, pub prop_asset_path: String, pub layer: Option, pub is_transferable: bool, pub is_portable: bool, pub is_droppable: bool, + /// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload) pub origin: PropOrigin, pub acquired_at: DateTime, } diff --git a/crates/chattyness-db/src/queries/inventory.rs b/crates/chattyness-db/src/queries/inventory.rs index 4c9d854..82efdcc 100644 --- a/crates/chattyness-db/src/queries/inventory.rs +++ b/crates/chattyness-db/src/queries/inventory.rs @@ -15,6 +15,7 @@ pub async fn list_user_inventory<'e>( r#" SELECT id, + COALESCE(server_prop_id, realm_prop_id) as prop_id, prop_name, prop_asset_path, layer, @@ -271,6 +272,7 @@ pub async fn acquire_server_prop<'e>( AND oc.is_claimed = false RETURNING id, + server_prop_id as prop_id, prop_name, prop_asset_path, layer, @@ -447,6 +449,7 @@ pub async fn acquire_realm_prop<'e>( AND oc.is_claimed = false RETURNING id, + realm_prop_id as prop_id, prop_name, prop_asset_path, layer, diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index ff18d4f..9157aaf 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -217,6 +217,33 @@ pub fn InventoryPopup( }); } + // Helper to update ownership status in server/realm props when an item is removed + let update_prop_ownership = move |item: &InventoryItem| { + use chattyness_db::models::PropOrigin; + + if let Some(prop_id) = item.prop_id { + match item.origin { + PropOrigin::ServerLibrary => { + set_server_props.update(|props| { + if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) { + prop.user_owns = false; + } + }); + } + PropOrigin::RealmLibrary => { + set_realm_props.update(|props| { + if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) { + prop.user_owns = false; + } + }); + } + PropOrigin::UserUpload => { + // User uploads don't appear in server/realm catalogs + } + } + } + }; + // Handle drop action via WebSocket #[cfg(feature = "hydrate")] let handle_drop = { @@ -229,11 +256,28 @@ pub fn InventoryPopup( send_fn(ClientMessage::DropProp { inventory_item_id: item_id, }); + // Find item index and update ownership before removing + let current_items = items.get(); + let removed_index = current_items.iter().position(|i| i.id == item_id); + if let Some(item) = current_items.iter().find(|i| i.id == item_id).cloned() { + update_prop_ownership(&item); + } // Optimistically remove from local list set_items.update(|items| { items.retain(|i| i.id != item_id); }); - set_selected_item.set(None); + // Select the next item (or previous if at end) + let updated_items = items.get(); + if let Some(idx) = removed_index { + if !updated_items.is_empty() { + let next_idx = idx.min(updated_items.len() - 1); + set_selected_item.set(Some(updated_items[next_idx].id)); + } else { + set_selected_item.set(None); + } + } else { + set_selected_item.set(None); + } } else { set_error.set(Some("Not connected to server".to_string())); } @@ -256,11 +300,28 @@ pub fn InventoryPopup( send_fn(ClientMessage::DeleteProp { inventory_item_id: item_id, }); + // Find item index and update ownership before removing + let current_items = items.get(); + let removed_index = current_items.iter().position(|i| i.id == item_id); + if let Some(item) = current_items.iter().find(|i| i.id == item_id).cloned() { + update_prop_ownership(&item); + } // Optimistically remove from local list set_items.update(|items| { items.retain(|i| i.id != item_id); }); - set_selected_item.set(None); + // Select the next item (or previous if at end) + let updated_items = items.get(); + if let Some(idx) = removed_index { + if !updated_items.is_empty() { + let next_idx = idx.min(updated_items.len() - 1); + set_selected_item.set(Some(updated_items[next_idx].id)); + } else { + set_selected_item.set(None); + } + } else { + set_selected_item.set(None); + } set_delete_confirm_item.set(None); } else { set_error.set(Some("Not connected to server".to_string())); @@ -310,6 +371,7 @@ pub fn InventoryPopup( on_delete_request=Callback::new(move |(id, name)| { set_delete_confirm_item.set(Some((id, name))); }) + on_delete_immediate=Callback::new(handle_delete) /> @@ -325,7 +387,7 @@ pub fn InventoryPopup( is_guest=is_guest acquire_endpoint="/api/server/inventory/request" on_acquired=Callback::new(move |_| { - // Trigger inventory refresh and reset loaded state + // Trigger inventory refresh set_my_inventory_loaded.set(false); set_inventory_refresh_trigger.update(|n| *n += 1); }) @@ -345,7 +407,7 @@ pub fn InventoryPopup( acquire_endpoint_is_realm=true realm_slug=realm_slug on_acquired=Callback::new(move |_| { - // Trigger inventory refresh and reset loaded state + // Trigger inventory refresh set_my_inventory_loaded.set(false); set_inventory_refresh_trigger.update(|n| *n += 1); }) @@ -393,24 +455,95 @@ fn MyInventoryTab( #[prop(into)] on_drop: Callback, #[prop(into)] deleting: Signal, #[prop(into)] on_delete_request: Callback<(Uuid, String)>, + /// Callback for immediate delete (Shift+Delete, no confirmation) + #[prop(into)] on_delete_immediate: Callback, ) -> impl IntoView { + // NodeRef to maintain focus on container after item removal + let container_ref = NodeRef::::new(); + + // Refocus container when selected item changes (after drop/delete) + #[cfg(feature = "hydrate")] + Effect::new(move |prev_selected: Option>| { + let current = selected_item.get(); + // If selection changed and we have a new selection, refocus container + if prev_selected.is_some() && current.is_some() { + if let Some(el) = container_ref.get() { + let _ = el.focus(); + } + } + current + }); + + // Keyboard shortcut handler + let handle_keydown = move |ev: leptos::web_sys::KeyboardEvent| { + // Don't handle if in an input field + #[cfg(feature = "hydrate")] + { + use wasm_bindgen::JsCast; + if let Some(target) = ev.target() { + if let Ok(element) = target.dyn_into::() { + let tag = element.tag_name().to_lowercase(); + if tag == "input" || tag == "textarea" { + return; + } + } + } + } + + // Get selected item info + let Some(item_id) = selected_item.get() else { return }; + let Some(item) = items.get().into_iter().find(|i| i.id == item_id) else { return }; + + // Only allow actions on droppable items + if !item.is_droppable { + return; + } + + let key = ev.key(); + let shift = ev.shift_key(); + + match key.as_str() { + "d" | "D" => { + ev.prevent_default(); + on_drop.run(item_id); + } + "Delete" => { + ev.prevent_default(); + if shift { + // Shift+Delete: immediate delete without confirmation + on_delete_immediate.run(item_id); + } else { + // Delete: delete with confirmation + on_delete_request.run((item_id, item.prop_name.clone())); + } + } + _ => {} + } + }; + view! { - // Loading state - -
-

"Loading inventory..."

-
-
+
+ // Loading state + +
+

"Loading inventory..."

+
+
- // Error state - -
-

{move || error.get().unwrap_or_default()}

-
-
+ // Error state + +
+

{move || error.get().unwrap_or_default()}

+
+
- // Empty state - + // Empty state +
"D""rop" }.into_any() + }} // Delete button - only shown for droppable props @@ -546,6 +683,7 @@ fn MyInventoryTab( }}
+
} } @@ -614,12 +752,37 @@ fn AcquisitionPropsTab( match response { Ok(resp) if resp.ok() => { - // Update local state to mark prop as owned + // Find the index of the acquired prop before updating + let current_props = props.get(); + let acquired_index = current_props.iter().position(|p| p.id == prop_id); + + // Update local state to mark prop as owned (shows green border) set_props.update(|props| { if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) { prop.user_owns = true; } }); + + // Select the next available (non-owned) prop + let updated_props = props.get(); + if let Some(idx) = acquired_index { + // Look for next non-owned prop starting from current position + let next_available = updated_props.iter() + .skip(idx + 1) + .find(|p| !p.user_owns && p.is_available && !(p.is_unique && p.is_claimed)) + .or_else(|| { + // If none after, look from the beginning + updated_props.iter() + .take(idx) + .find(|p| !p.user_owns && p.is_available && !(p.is_unique && p.is_claimed)) + }); + + if let Some(next_prop) = next_available { + set_selected_prop.set(Some(next_prop.id)); + } + // If no more available props, keep current selection (now shows as owned) + } + // Notify parent to refresh inventory on_acquired.run(()); } @@ -647,7 +810,31 @@ fn AcquisitionPropsTab( } }); + // Keyboard shortcut handler for Enter to acquire + let handle_keydown = { + let do_acquire = do_acquire.clone(); + move |ev: leptos::web_sys::KeyboardEvent| { + let key = ev.key(); + if key == "Enter" { + // Get selected prop info + let Some(prop_id) = selected_prop.get() else { return }; + let Some(prop) = props.get().into_iter().find(|p| p.id == prop_id) else { return }; + + // Only acquire if not already owned, not claimed (if unique), available, and not a guest + if !is_guest.get() && !prop.user_owns && prop.is_available && !(prop.is_unique && prop.is_claimed) { + ev.prevent_default(); + do_acquire.run(prop_id); + } + } + } + }; + view! { +
// Loading state
@@ -703,18 +890,20 @@ fn AcquisitionPropsTab( format!("/static/{}", prop.asset_path) }; - // Visual indicator for ownership status - let user_owns = prop.user_owns; + // Reactive lookup for ownership status (updates when props signal changes) + let user_owns = move || { + props.get().iter().find(|p| p.id == prop_id).map(|p| p.user_owns).unwrap_or(false) + }; let is_claimed = prop.is_claimed; view! { @@ -797,6 +986,7 @@ fn AcquisitionPropsTab( }}
+
} }