fix: inventory interface and hot keys
This commit is contained in:
parent
5f543ca6c4
commit
5e14481714
3 changed files with 221 additions and 25 deletions
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
// 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);
|
||||
});
|
||||
// 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,8 +455,79 @@ 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! {
|
||||
<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">
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue