feat: add delete form inventory

This commit is contained in:
Evan Carroll 2026-01-23 17:42:41 -06:00
parent 6e637a29cd
commit 73f9c95e37
4 changed files with 123 additions and 3 deletions

View file

@ -19,7 +19,7 @@ use uuid::Uuid;
use chattyness_db::{
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, ForcedAvatarReason, User},
queries::{avatars, channel_members, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users},
queries::{avatars, channel_members, inventory, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users},
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
};
use chattyness_error::AppError;
@ -711,6 +711,37 @@ async fn handle_socket(
}
}
}
ClientMessage::DeleteProp { inventory_item_id } => {
match inventory::drop_inventory_item(
&mut *recv_conn,
user_id,
inventory_item_id,
)
.await
{
Ok(()) => {
let _ = direct_tx.send(ServerMessage::PropDeleted {
inventory_item_id,
}).await;
}
Err(e) => {
let (code, message) = match &e {
chattyness_error::AppError::Forbidden(msg) => (
"PROP_NOT_DELETABLE".to_string(),
msg.clone(),
),
chattyness_error::AppError::NotFound(msg) => {
("PROP_NOT_FOUND".to_string(), msg.clone())
}
_ => (
"DELETE_FAILED".to_string(),
format!("{:?}", e),
),
};
let _ = direct_tx.send(ServerMessage::Error { code, message }).await;
}
}
}
ClientMessage::PickUpProp { loose_prop_id } => {
// Check if prop is locked
let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await {

View file

@ -8,7 +8,7 @@ use chattyness_db::models::{InventoryItem, PropAcquisitionInfo};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
use super::modals::{GuestLockedOverlay, Modal};
use super::modals::{ConfirmModal, GuestLockedOverlay, Modal};
use super::tabs::{Tab, TabBar};
use super::ws_client::WsSender;
@ -45,6 +45,8 @@ pub fn InventoryPopup(
let (error, set_error) = signal(Option::<String>::None);
let (selected_item, set_selected_item) = signal(Option::<Uuid>::None);
let (dropping, set_dropping) = signal(false);
let (deleting, set_deleting) = signal(false);
let (delete_confirm_item, set_delete_confirm_item) = signal(Option::<(Uuid, String)>::None);
// Server props state (with acquisition info for authenticated users)
let (server_props, set_server_props) = signal(Vec::<PropAcquisitionInfo>::new());
@ -244,6 +246,33 @@ pub fn InventoryPopup(
#[cfg(not(feature = "hydrate"))]
let handle_drop = |_item_id: Uuid| {};
// Handle delete action via WebSocket (permanent deletion)
#[cfg(feature = "hydrate")]
let handle_delete = {
move |item_id: Uuid| {
set_deleting.set(true);
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::DeleteProp {
inventory_item_id: item_id,
});
// Optimistically remove from local list
set_items.update(|items| {
items.retain(|i| i.id != item_id);
});
set_selected_item.set(None);
set_delete_confirm_item.set(None);
} else {
set_error.set(Some("Not connected to server".to_string()));
}
});
set_deleting.set(false);
}
};
#[cfg(not(feature = "hydrate"))]
let handle_delete = |_item_id: Uuid| {};
view! {
<Modal
open=open
@ -277,6 +306,10 @@ pub fn InventoryPopup(
set_selected_item=set_selected_item
dropping=dropping
on_drop=Callback::new(handle_drop)
deleting=deleting
on_delete_request=Callback::new(move |(id, name)| {
set_delete_confirm_item.set(Some((id, name)));
})
/>
</Show>
@ -324,6 +357,25 @@ pub fn InventoryPopup(
<Show when=move || is_guest.get()>
<GuestLockedOverlay />
</Show>
// Delete confirmation modal
{move || {
delete_confirm_item.get().map(|(item_id, item_name)| {
view! {
<ConfirmModal
open=Signal::derive(|| true)
title="Delete Prop?"
message=format!("Permanently delete '{}'? This cannot be undone.", item_name)
confirm_text="Delete"
cancel_text="Cancel"
destructive=true
pending=Signal::derive(move || deleting.get())
on_confirm=Callback::new(move |_| handle_delete(item_id))
on_cancel=Callback::new(move |_| set_delete_confirm_item.set(None))
/>
}
})
}}
</div>
</Modal>
}
@ -339,6 +391,8 @@ fn MyInventoryTab(
set_selected_item: WriteSignal<Option<Uuid>>,
#[prop(into)] dropping: Signal<bool>,
#[prop(into)] on_drop: Callback<Uuid>,
#[prop(into)] deleting: Signal<bool>,
#[prop(into)] on_delete_request: Callback<(Uuid, String)>,
) -> impl IntoView {
view! {
// Loading state
@ -423,8 +477,11 @@ fn MyInventoryTab(
let item_id = selected_item.get()?;
let item = items.get().into_iter().find(|i| i.id == item_id)?;
let on_drop = on_drop.clone();
let on_delete_request = on_delete_request.clone();
let is_dropping = dropping.get();
let is_deleting = deleting.get();
let is_droppable = item.is_droppable;
let item_name = item.prop_name.clone();
Some(view! {
<div class="mt-4 pt-4 border-t border-gray-700">
@ -452,10 +509,25 @@ fn MyInventoryTab(
}
}
disabled=is_dropping || !is_droppable
title=if is_droppable { "" } 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" }}
</button>
// Delete button - only shown for droppable props
<Show when=move || is_droppable>
<button
type="button"
class="px-4 py-2 bg-red-800 hover:bg-red-900 text-white rounded-lg transition-colors disabled:opacity-50"
on:click={
let name = item_name.clone();
move |_| on_delete_request.run((item_id, name.clone()))
}
disabled=is_dropping || is_deleting
title="Permanently delete this prop"
>
"Delete"
</button>
</Show>
// Transfer button (disabled for now)
<Show when=move || item.is_transferable>
<button

View file

@ -662,6 +662,11 @@ fn handle_server_message(
// Treat expired props the same as picked up (remove from display)
PostAction::PropPickedUp(prop_id)
}
ServerMessage::PropDeleted { inventory_item_id: _ } => {
// Inventory deletion is handled optimistically in the UI
// No scene state change needed
PostAction::None
}
ServerMessage::PropRefresh { prop } => {
PostAction::PropRefresh(prop)
}