diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index 70eb3cb..b103937 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -311,17 +311,18 @@ pub async fn pick_up_loose_prop<'e>( '[]'::jsonb, now() FROM source_info si - RETURNING id, prop_name, prop_asset_path, layer, is_transferable, is_portable, is_droppable, acquired_at + RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer, is_transferable, is_portable, is_droppable, origin, acquired_at ) SELECT ii.id, + COALESCE(ii.server_prop_id, ii.realm_prop_id) as prop_id, ii.prop_name, ii.prop_asset_path, ii.layer, ii.is_transferable, ii.is_portable, ii.is_droppable, - 'server_library'::server.prop_origin as origin, + ii.origin, ii.acquired_at FROM inserted_item ii "#, @@ -620,3 +621,27 @@ pub async fn cleanup_expired_props<'e>(executor: impl PgExecutor<'e>) -> Result< Ok(result.rows_affected()) } + +/// Delete a loose prop from the scene (moderator action). +/// +/// This permanently removes the prop without adding it to anyone's inventory. +pub async fn delete_loose_prop<'e>( + executor: impl PgExecutor<'e>, + loose_prop_id: Uuid, +) -> Result<(), AppError> { + let result = sqlx::query( + r#" + DELETE FROM scene.loose_props + WHERE id = $1 + "#, + ) + .bind(loose_prop_id) + .execute(executor) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Loose prop not found".to_string())); + } + + Ok(()) +} diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 5b7bc83..821209b 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -140,6 +140,12 @@ pub enum ClientMessage { /// Inventory item ID to delete. inventory_item_id: Uuid, }, + + /// Delete a loose prop from the scene (moderator only). + DeleteLooseProp { + /// The loose prop ID to delete. + loose_prop_id: Uuid, + }, } /// Server-to-client WebSocket messages. @@ -263,6 +269,14 @@ pub enum ServerMessage { inventory_item_id: Uuid, }, + /// A loose prop was deleted from the scene (by a moderator). + LoosePropDeleted { + /// ID of the deleted loose prop. + prop_id: Uuid, + /// User ID who deleted it. + deleted_by_user_id: Uuid, + }, + /// A prop was updated (scale changed) - clients should update their local copy. PropRefresh { /// The updated prop with all current values. diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index c053192..270886a 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -1658,6 +1658,51 @@ async fn handle_socket( } } } + ClientMessage::DeleteLooseProp { loose_prop_id } => { + // Check if user is a moderator + let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await { + Ok(result) => result, + Err(e) => { + tracing::error!("[WS] Failed to check moderator status: {:?}", e); + false + } + }; + + if !is_mod { + let _ = direct_tx.send(ServerMessage::Error { + code: "NOT_MODERATOR".to_string(), + message: "You do not have permission to delete props".to_string(), + }).await; + continue; + } + + // Delete the prop + match loose_props::delete_loose_prop( + &mut *recv_conn, + loose_prop_id, + ).await { + Ok(()) => { + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} deleted prop {}", + user_id, + loose_prop_id + ); + // Broadcast the deletion to all users in the channel + let _ = tx.send(ServerMessage::LoosePropDeleted { + prop_id: loose_prop_id, + deleted_by_user_id: user_id, + }); + } + Err(e) => { + tracing::error!("[WS] Delete prop failed: {:?}", e); + let _ = direct_tx.send(ServerMessage::Error { + code: "DELETE_PROP_FAILED".to_string(), + message: format!("{:?}", e), + }).await; + } + } + } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs index f93c00b..536b00b 100644 --- a/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs +++ b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs @@ -65,13 +65,6 @@ pub fn LoosePropCanvas( let canvas_x = screen_x - prop_size / 2.0; let canvas_y = screen_y - prop_size / 2.0; - // Add amber dashed border for locked props - let border_style = if p.is_locked { - "border: 2px dashed #f59e0b; box-sizing: border-box;" - } else { - "" - }; - format!( "position: absolute; \ left: 0; top: 0; \ @@ -79,8 +72,8 @@ pub fn LoosePropCanvas( z-index: {}; \ pointer-events: auto; \ width: {}px; \ - height: {}px; {}", - canvas_x, canvas_y, z_index, prop_size, prop_size, border_style + height: {}px;", + canvas_x, canvas_y, z_index, prop_size, prop_size ) }; diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 211c964..a69feae 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -61,6 +61,7 @@ pub fn RealmSceneViewer( #[prop(optional, into)] on_prop_scale_update: Option>, #[prop(optional, into)] on_prop_move: Option>, #[prop(optional, into)] on_prop_lock_toggle: Option>, + #[prop(optional, into)] on_prop_delete: Option>, ) -> impl IntoView { let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default)); @@ -131,6 +132,7 @@ pub fn RealmSceneViewer( let move_mode_preview_position = RwSignal::new((0.0_f64, 0.0_f64)); let (move_mode_prop_scale, set_move_mode_prop_scale) = signal(1.0_f32); let (prop_context_is_locked, set_prop_context_is_locked) = signal(false); + let (prop_context_name, set_prop_context_name) = signal(Option::::None); // Click handler for movement (props are now handled via context menu) #[cfg(feature = "hydrate")] @@ -221,6 +223,7 @@ pub fn RealmSceneViewer( set_scale_mode_initial_scale.set(prop.scale); set_prop_context_is_locked.set(prop.is_locked); + set_prop_context_name.set(Some(prop.prop_name.clone())); set_move_mode_prop_scale.set(prop.scale); let rect = canvas.get_bounding_client_rect(); set_scale_mode_prop_center.set(( @@ -598,7 +601,9 @@ pub fn RealmSceneViewer( { @@ -649,6 +656,13 @@ pub fn RealmSceneViewer( } } } + "delete" => { + if let Some(prop_id) = prop_context_menu_target.get() { + if let Some(ref callback) = on_prop_delete { + callback.run(prop_id); + } + } + } _ => {} } set_prop_context_menu_open.set(false); @@ -657,6 +671,7 @@ pub fn RealmSceneViewer( on_close=Callback::new(move |_: ()| { set_prop_context_menu_open.set(false); set_prop_context_menu_target.set(None); + set_prop_context_name.set(None); }) /> { + // Treat deleted props the same as picked up (remove from display) + PostAction::PropPickedUp(prop_id) + } ServerMessage::PropRefresh { prop } => { PostAction::PropRefresh(prop) } diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 5686a4f..35125d5 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -1244,6 +1244,16 @@ pub fn RealmPage() -> impl IntoView { } }); }); + #[cfg(feature = "hydrate")] + let ws_for_prop_delete = ws_sender_clone.clone(); + let on_prop_delete_cb = Callback::new(move |prop_id: Uuid| { + #[cfg(feature = "hydrate")] + ws_for_prop_delete.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::DeleteLooseProp { loose_prop_id: prop_id }); + } + }); + }); view! {
impl IntoView { on_prop_scale_update=on_prop_scale_update_cb on_prop_move=on_prop_move_cb on_prop_lock_toggle=on_prop_lock_toggle_cb + on_prop_delete=on_prop_delete_cb />