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))]
pub struct InventoryItem {
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_asset_path: String,
pub layer: Option<AvatarLayer>,
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<Utc>,
}

View file

@ -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,

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
#[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)
/>
</Show>
@ -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<Uuid>,
#[prop(into)] deleting: Signal<bool>,
#[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 {
// 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! {
// 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>
<div
node_ref=container_ref
tabindex="0"
on:keydown=handle_keydown
class="outline-none"
>
// 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
<Show when=move || error.get().is_some()>
<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>
</div>
</Show>
// Error state
<Show when=move || error.get().is_some()>
<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>
</div>
</Show>
// Empty state
<Show when=move || !loading.get() && error.get().is_none() && items.get().is_empty()>
// Empty state
<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="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">
@ -511,7 +644,11 @@ fn MyInventoryTab(
disabled=is_dropping || !is_droppable
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>
// Delete button - only shown for droppable props
<Show when=move || is_droppable>
@ -546,6 +683,7 @@ fn MyInventoryTab(
}}
</div>
</Show>
</div>
}
}
@ -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! {
<div
tabindex="0"
on:keydown=handle_keydown
class="outline-none"
>
// Loading state
<Show when=move || loading.get()>
<div class="flex items-center justify-center py-12">
@ -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! {
<button
type="button"
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() {
"border-blue-500 bg-blue-900/30"
} else if user_owns {
} else if user_owns() {
"border-green-500/50 bg-green-900/20"
} else if is_claimed {
"border-red-500/50 bg-red-900/20 opacity-50"
@ -735,7 +924,7 @@ fn AcquisitionPropsTab(
class="w-full h-full object-contain"
/>
// 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" />
</Show>
</button>
@ -797,6 +986,7 @@ fn AcquisitionPropsTab(
}}
</div>
</Show>
</div>
}
}