fix: inventory interface and hot keys

This commit is contained in:
Evan Carroll 2026-01-24 00:18:54 -06:00
parent 5f543ca6c4
commit 5e14481714
3 changed files with 221 additions and 25 deletions

View file

@ -827,12 +827,15 @@ impl std::fmt::Display for PropOrigin {
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct InventoryItem { pub struct InventoryItem {
pub id: Uuid, pub id: Uuid,
/// The source prop ID (use `origin` to determine which table: Server or Realm)
pub prop_id: Option<Uuid>,
pub prop_name: String, pub prop_name: String,
pub prop_asset_path: String, pub prop_asset_path: String,
pub layer: Option<AvatarLayer>, pub layer: Option<AvatarLayer>,
pub is_transferable: bool, pub is_transferable: bool,
pub is_portable: bool, pub is_portable: bool,
pub is_droppable: bool, pub is_droppable: bool,
/// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload)
pub origin: PropOrigin, pub origin: PropOrigin,
pub acquired_at: DateTime<Utc>, pub acquired_at: DateTime<Utc>,
} }

View file

@ -15,6 +15,7 @@ pub async fn list_user_inventory<'e>(
r#" r#"
SELECT SELECT
id, id,
COALESCE(server_prop_id, realm_prop_id) as prop_id,
prop_name, prop_name,
prop_asset_path, prop_asset_path,
layer, layer,
@ -271,6 +272,7 @@ pub async fn acquire_server_prop<'e>(
AND oc.is_claimed = false AND oc.is_claimed = false
RETURNING RETURNING
id, id,
server_prop_id as prop_id,
prop_name, prop_name,
prop_asset_path, prop_asset_path,
layer, layer,
@ -447,6 +449,7 @@ pub async fn acquire_realm_prop<'e>(
AND oc.is_claimed = false AND oc.is_claimed = false
RETURNING RETURNING
id, id,
realm_prop_id as prop_id,
prop_name, prop_name,
prop_asset_path, prop_asset_path,
layer, layer,

View file

@ -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 // Handle drop action via WebSocket
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
let handle_drop = { let handle_drop = {
@ -229,11 +256,28 @@ pub fn InventoryPopup(
send_fn(ClientMessage::DropProp { send_fn(ClientMessage::DropProp {
inventory_item_id: item_id, 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 // Optimistically remove from local list
set_items.update(|items| { set_items.update(|items| {
items.retain(|i| i.id != item_id); 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 { } else {
set_error.set(Some("Not connected to server".to_string())); set_error.set(Some("Not connected to server".to_string()));
} }
@ -256,11 +300,28 @@ pub fn InventoryPopup(
send_fn(ClientMessage::DeleteProp { send_fn(ClientMessage::DeleteProp {
inventory_item_id: item_id, 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 // Optimistically remove from local list
set_items.update(|items| { set_items.update(|items| {
items.retain(|i| i.id != item_id); 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); set_delete_confirm_item.set(None);
} else { } else {
set_error.set(Some("Not connected to server".to_string())); 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)| { on_delete_request=Callback::new(move |(id, name)| {
set_delete_confirm_item.set(Some((id, name))); set_delete_confirm_item.set(Some((id, name)));
}) })
on_delete_immediate=Callback::new(handle_delete)
/> />
</Show> </Show>
@ -325,7 +387,7 @@ pub fn InventoryPopup(
is_guest=is_guest is_guest=is_guest
acquire_endpoint="/api/server/inventory/request" acquire_endpoint="/api/server/inventory/request"
on_acquired=Callback::new(move |_| { on_acquired=Callback::new(move |_| {
// Trigger inventory refresh and reset loaded state // Trigger inventory refresh
set_my_inventory_loaded.set(false); set_my_inventory_loaded.set(false);
set_inventory_refresh_trigger.update(|n| *n += 1); set_inventory_refresh_trigger.update(|n| *n += 1);
}) })
@ -345,7 +407,7 @@ pub fn InventoryPopup(
acquire_endpoint_is_realm=true acquire_endpoint_is_realm=true
realm_slug=realm_slug realm_slug=realm_slug
on_acquired=Callback::new(move |_| { on_acquired=Callback::new(move |_| {
// Trigger inventory refresh and reset loaded state // Trigger inventory refresh
set_my_inventory_loaded.set(false); set_my_inventory_loaded.set(false);
set_inventory_refresh_trigger.update(|n| *n += 1); set_inventory_refresh_trigger.update(|n| *n += 1);
}) })
@ -393,24 +455,95 @@ fn MyInventoryTab(
#[prop(into)] on_drop: Callback<Uuid>, #[prop(into)] on_drop: Callback<Uuid>,
#[prop(into)] deleting: Signal<bool>, #[prop(into)] deleting: Signal<bool>,
#[prop(into)] on_delete_request: Callback<(Uuid, String)>, #[prop(into)] on_delete_request: Callback<(Uuid, String)>,
/// Callback for immediate delete (Shift+Delete, no confirmation)
#[prop(into)] on_delete_immediate: Callback<Uuid>,
) -> impl IntoView { ) -> impl IntoView {
// NodeRef to maintain focus on container after item removal
let container_ref = NodeRef::<leptos::html::Div>::new();
// Refocus container when selected item changes (after drop/delete)
#[cfg(feature = "hydrate")]
Effect::new(move |prev_selected: Option<Option<Uuid>>| {
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::<leptos::web_sys::HtmlElement>() {
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! { view! {
// Loading state <div
<Show when=move || loading.get()> node_ref=container_ref
<div class="flex items-center justify-center py-12"> tabindex="0"
<p class="text-gray-400">"Loading inventory..."</p> on:keydown=handle_keydown
</div> class="outline-none"
</Show> >
// Loading state
<Show when=move || loading.get()>
<div class="flex items-center justify-center py-12">
<p class="text-gray-400">"Loading inventory..."</p>
</div>
</Show>
// Error state // Error state
<Show when=move || error.get().is_some()> <Show when=move || error.get().is_some()>
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4"> <div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4">
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p> <p class="text-red-400">{move || error.get().unwrap_or_default()}</p>
</div> </div>
</Show> </Show>
// Empty state // Empty state
<Show when=move || !loading.get() && error.get().is_none() && items.get().is_empty()> <Show when=move || !loading.get() && error.get().is_none() && items.get().is_empty()>
<div class="flex flex-col items-center justify-center py-12 text-center"> <div class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4"> <div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
@ -511,7 +644,11 @@ fn MyInventoryTab(
disabled=is_dropping || !is_droppable disabled=is_dropping || !is_droppable
title=if is_droppable { "Drop prop to scene canvas" } else { "Essential prop cannot be dropped" } title=if is_droppable { "Drop prop to scene canvas" } else { "Essential prop cannot be dropped" }
> >
{if is_dropping { "Dropping..." } else { "Drop" }} {if is_dropping {
view! { "Dropping..." }.into_any()
} else {
view! { <span><u>"D"</u>"rop"</span> }.into_any()
}}
</button> </button>
// Delete button - only shown for droppable props // Delete button - only shown for droppable props
<Show when=move || is_droppable> <Show when=move || is_droppable>
@ -546,6 +683,7 @@ fn MyInventoryTab(
}} }}
</div> </div>
</Show> </Show>
</div>
} }
} }
@ -614,12 +752,37 @@ fn AcquisitionPropsTab(
match response { match response {
Ok(resp) if resp.ok() => { 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| { set_props.update(|props| {
if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) { if let Some(prop) = props.iter_mut().find(|p| p.id == prop_id) {
prop.user_owns = true; 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 // Notify parent to refresh inventory
on_acquired.run(()); 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! { view! {
<div
tabindex="0"
on:keydown=handle_keydown
class="outline-none"
>
// Loading state // Loading state
<Show when=move || loading.get()> <Show when=move || loading.get()>
<div class="flex items-center justify-center py-12"> <div class="flex items-center justify-center py-12">
@ -703,18 +890,20 @@ fn AcquisitionPropsTab(
format!("/static/{}", prop.asset_path) format!("/static/{}", prop.asset_path)
}; };
// Visual indicator for ownership status // Reactive lookup for ownership status (updates when props signal changes)
let user_owns = prop.user_owns; 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; let is_claimed = prop.is_claimed;
view! { view! {
<button <button
type="button" type="button"
class=move || format!( class=move || format!(
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 relative {}", "aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 relative overflow-hidden {}",
if is_selected() { if is_selected() {
"border-blue-500 bg-blue-900/30" "border-blue-500 bg-blue-900/30"
} else if user_owns { } else if user_owns() {
"border-green-500/50 bg-green-900/20" "border-green-500/50 bg-green-900/20"
} else if is_claimed { } else if is_claimed {
"border-red-500/50 bg-red-900/20 opacity-50" "border-red-500/50 bg-red-900/20 opacity-50"
@ -735,7 +924,7 @@ fn AcquisitionPropsTab(
class="w-full h-full object-contain" class="w-full h-full object-contain"
/> />
// Ownership badge // Ownership badge
<Show when=move || user_owns> <Show when=user_owns>
<span class="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full transform translate-x-1 -translate-y-1" title="Owned" /> <span class="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full transform translate-x-1 -translate-y-1" title="Owned" />
</Show> </Show>
</button> </button>
@ -797,6 +986,7 @@ fn AcquisitionPropsTab(
}} }}
</div> </div>
</Show> </Show>
</div>
} }
} }