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))]
|
#[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>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
// 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);
|
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);
|
||||||
});
|
});
|
||||||
|
// 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);
|
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,8 +455,79 @@ 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! {
|
||||||
|
<div
|
||||||
|
node_ref=container_ref
|
||||||
|
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">
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue