Compare commits
No commits in common. "4f0f88504ae070c539ca06645bddc647e5e72ac11b63bfb59206b78487b26813" and "98590f63e77e96f8196dcf25158af3b0cf26fa61a68da8de1d31aec4835544f3" have entirely different histories.
4f0f88504a
...
98590f63e7
11 changed files with 70 additions and 505 deletions
|
|
@ -822,81 +822,21 @@ impl std::fmt::Display for PropOrigin {
|
|||
}
|
||||
}
|
||||
|
||||
/// Source of a prop, combining origin type and ID.
|
||||
///
|
||||
/// This replaces the pattern of two nullable UUID columns (server_prop_id, realm_prop_id)
|
||||
/// with a single enum that encodes both the source type and ID.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PropSource {
|
||||
/// Prop from the server-wide library (server.props)
|
||||
Server(Uuid),
|
||||
/// Prop from a realm-specific library (realm.props)
|
||||
Realm(Uuid),
|
||||
/// Prop uploaded by a user (auth.uploads) - future use
|
||||
Upload(Uuid),
|
||||
}
|
||||
|
||||
impl PropSource {
|
||||
/// Extract the UUID from any variant.
|
||||
pub fn id(&self) -> Uuid {
|
||||
match self {
|
||||
PropSource::Server(id) | PropSource::Realm(id) | PropSource::Upload(id) => *id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PropSource {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PropSource::Server(id) => write!(f, "server:{}", id),
|
||||
PropSource::Realm(id) => write!(f, "realm:{}", id),
|
||||
PropSource::Upload(id) => write!(f, "upload:{}", id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PropSource> for PropOrigin {
|
||||
fn from(source: &PropSource) -> Self {
|
||||
match source {
|
||||
PropSource::Server(_) => PropOrigin::ServerLibrary,
|
||||
PropSource::Realm(_) => PropOrigin::RealmLibrary,
|
||||
PropSource::Upload(_) => PropOrigin::UserUpload,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An inventory item (user-owned prop).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[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>,
|
||||
}
|
||||
|
||||
impl InventoryItem {
|
||||
/// Get the prop source combining `prop_id` and `origin` into a `PropSource`.
|
||||
///
|
||||
/// Returns `None` if `prop_id` is `None` (shouldn't happen in practice).
|
||||
pub fn prop_source(&self) -> Option<PropSource> {
|
||||
self.prop_id.map(|id| match self.origin {
|
||||
PropOrigin::ServerLibrary => PropSource::Server(id),
|
||||
PropOrigin::RealmLibrary => PropSource::Realm(id),
|
||||
PropOrigin::UserUpload => PropSource::Upload(id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for inventory list.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InventoryResponse {
|
||||
|
|
@ -939,69 +879,16 @@ pub struct PropAcquisitionListResponse {
|
|||
pub props: Vec<PropAcquisitionInfo>,
|
||||
}
|
||||
|
||||
/// Intermediate row type for database queries returning loose props.
|
||||
///
|
||||
/// This maps directly to database columns (with nullable server_prop_id/realm_prop_id)
|
||||
/// and is converted to `LooseProp` with a `PropSource` enum.
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct LoosePropRow {
|
||||
/// A prop dropped in a channel, available for pickup.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
pub struct LooseProp {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub server_prop_id: Option<Uuid>,
|
||||
pub realm_prop_id: Option<Uuid>,
|
||||
pub position_x: f64,
|
||||
pub position_y: f64,
|
||||
pub scale: f32,
|
||||
pub dropped_by: Option<Uuid>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub prop_name: String,
|
||||
pub prop_asset_path: String,
|
||||
pub is_locked: bool,
|
||||
pub locked_by: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<LoosePropRow> for LooseProp {
|
||||
fn from(row: LoosePropRow) -> Self {
|
||||
let source = if let Some(id) = row.server_prop_id {
|
||||
PropSource::Server(id)
|
||||
} else if let Some(id) = row.realm_prop_id {
|
||||
PropSource::Realm(id)
|
||||
} else {
|
||||
// Database CHECK constraint ensures exactly one is set,
|
||||
// but we need a fallback. This should never happen.
|
||||
panic!("LoosePropRow has neither server_prop_id nor realm_prop_id set")
|
||||
};
|
||||
|
||||
LooseProp {
|
||||
id: row.id,
|
||||
channel_id: row.channel_id,
|
||||
source,
|
||||
position_x: row.position_x,
|
||||
position_y: row.position_y,
|
||||
scale: row.scale,
|
||||
dropped_by: row.dropped_by,
|
||||
expires_at: row.expires_at,
|
||||
created_at: row.created_at,
|
||||
prop_name: row.prop_name,
|
||||
prop_asset_path: row.prop_asset_path,
|
||||
is_locked: row.is_locked,
|
||||
locked_by: row.locked_by,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A prop dropped in a channel, available for pickup.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LooseProp {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
/// The source of this prop (server library, realm library, or user upload).
|
||||
pub source: PropSource,
|
||||
pub position_x: f64,
|
||||
pub position_y: f64,
|
||||
/// Scale factor (0.1 - 10.0) inherited from prop definition at drop time.
|
||||
pub scale: f32,
|
||||
pub dropped_by: Option<Uuid>,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ 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,
|
||||
|
|
@ -272,7 +271,6 @@ 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,
|
||||
|
|
@ -449,7 +447,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
use sqlx::PgExecutor;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{InventoryItem, LooseProp, LoosePropRow, PropSource};
|
||||
use crate::models::{InventoryItem, LooseProp};
|
||||
use chattyness_error::{AppError, OptionExt};
|
||||
|
||||
/// Ensure an instance exists for a scene.
|
||||
|
|
@ -37,7 +37,7 @@ pub async fn list_channel_loose_props<'e>(
|
|||
executor: impl PgExecutor<'e>,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<LooseProp>, AppError> {
|
||||
let rows = sqlx::query_as::<_, LoosePropRow>(
|
||||
let props = sqlx::query_as::<_, LooseProp>(
|
||||
r#"
|
||||
SELECT
|
||||
lp.id,
|
||||
|
|
@ -66,7 +66,7 @@ pub async fn list_channel_loose_props<'e>(
|
|||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(LooseProp::from).collect())
|
||||
Ok(props)
|
||||
}
|
||||
|
||||
/// Drop a prop from inventory to the canvas.
|
||||
|
|
@ -224,22 +224,12 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
Some(prop_name),
|
||||
Some(prop_asset_path),
|
||||
)) => {
|
||||
// Construct PropSource from the nullable columns
|
||||
let source = if let Some(sid) = server_prop_id {
|
||||
PropSource::Server(sid)
|
||||
} else if let Some(rid) = realm_prop_id {
|
||||
PropSource::Realm(rid)
|
||||
} else {
|
||||
return Err(AppError::Internal(
|
||||
"Dropped prop has neither server_prop_id nor realm_prop_id".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// Success! Convert f32 positions to f64.
|
||||
Ok(LooseProp {
|
||||
id,
|
||||
channel_id,
|
||||
source,
|
||||
server_prop_id,
|
||||
realm_prop_id,
|
||||
position_x: position_x.into(),
|
||||
position_y: position_y.into(),
|
||||
scale,
|
||||
|
|
@ -321,18 +311,17 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
'[]'::jsonb,
|
||||
now()
|
||||
FROM source_info si
|
||||
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer, is_transferable, is_portable, is_droppable, origin, acquired_at
|
||||
RETURNING id, prop_name, prop_asset_path, layer, is_transferable, is_portable, is_droppable, 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,
|
||||
ii.origin,
|
||||
'server_library'::server.prop_origin as origin,
|
||||
ii.acquired_at
|
||||
FROM inserted_item ii
|
||||
"#,
|
||||
|
|
@ -362,7 +351,7 @@ pub async fn update_loose_prop_scale<'e>(
|
|||
));
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||
let prop = sqlx::query_as::<_, LooseProp>(
|
||||
r#"
|
||||
WITH updated AS (
|
||||
UPDATE scene.loose_props
|
||||
|
|
@ -409,7 +398,7 @@ pub async fn update_loose_prop_scale<'e>(
|
|||
.await?
|
||||
.or_not_found("Loose prop (may have expired)")?;
|
||||
|
||||
Ok(LooseProp::from(row))
|
||||
Ok(prop)
|
||||
}
|
||||
|
||||
/// Get a loose prop by ID.
|
||||
|
|
@ -417,7 +406,7 @@ pub async fn get_loose_prop_by_id<'e>(
|
|||
executor: impl PgExecutor<'e>,
|
||||
loose_prop_id: Uuid,
|
||||
) -> Result<Option<LooseProp>, AppError> {
|
||||
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||
let prop = sqlx::query_as::<_, LooseProp>(
|
||||
r#"
|
||||
SELECT
|
||||
lp.id,
|
||||
|
|
@ -445,7 +434,7 @@ pub async fn get_loose_prop_by_id<'e>(
|
|||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(LooseProp::from))
|
||||
Ok(prop)
|
||||
}
|
||||
|
||||
/// Move a loose prop to a new position.
|
||||
|
|
@ -455,7 +444,7 @@ pub async fn move_loose_prop<'e>(
|
|||
x: f64,
|
||||
y: f64,
|
||||
) -> Result<LooseProp, AppError> {
|
||||
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||
let prop = sqlx::query_as::<_, LooseProp>(
|
||||
r#"
|
||||
WITH updated AS (
|
||||
UPDATE scene.loose_props
|
||||
|
|
@ -503,7 +492,7 @@ pub async fn move_loose_prop<'e>(
|
|||
.await?
|
||||
.or_not_found("Loose prop (may have expired)")?;
|
||||
|
||||
Ok(LooseProp::from(row))
|
||||
Ok(prop)
|
||||
}
|
||||
|
||||
/// Lock a loose prop (moderator only).
|
||||
|
|
@ -512,7 +501,7 @@ pub async fn lock_loose_prop<'e>(
|
|||
loose_prop_id: Uuid,
|
||||
locked_by: Uuid,
|
||||
) -> Result<LooseProp, AppError> {
|
||||
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||
let prop = sqlx::query_as::<_, LooseProp>(
|
||||
r#"
|
||||
WITH updated AS (
|
||||
UPDATE scene.loose_props
|
||||
|
|
@ -559,7 +548,7 @@ pub async fn lock_loose_prop<'e>(
|
|||
.await?
|
||||
.or_not_found("Loose prop (may have expired)")?;
|
||||
|
||||
Ok(LooseProp::from(row))
|
||||
Ok(prop)
|
||||
}
|
||||
|
||||
/// Unlock a loose prop (moderator only).
|
||||
|
|
@ -567,7 +556,7 @@ pub async fn unlock_loose_prop<'e>(
|
|||
executor: impl PgExecutor<'e>,
|
||||
loose_prop_id: Uuid,
|
||||
) -> Result<LooseProp, AppError> {
|
||||
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||
let prop = sqlx::query_as::<_, LooseProp>(
|
||||
r#"
|
||||
WITH updated AS (
|
||||
UPDATE scene.loose_props
|
||||
|
|
@ -613,7 +602,7 @@ pub async fn unlock_loose_prop<'e>(
|
|||
.await?
|
||||
.or_not_found("Loose prop (may have expired)")?;
|
||||
|
||||
Ok(LooseProp::from(row))
|
||||
Ok(prop)
|
||||
}
|
||||
|
||||
/// Delete expired loose props.
|
||||
|
|
@ -631,27 +620,3 @@ 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,12 +140,6 @@ 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.
|
||||
|
|
@ -269,14 +263,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1658,51 +1658,6 @@ 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) => {
|
||||
|
|
|
|||
|
|
@ -217,33 +217,6 @@ 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 = {
|
||||
|
|
@ -256,28 +229,11 @@ 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()));
|
||||
}
|
||||
|
|
@ -300,28 +256,11 @@ 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()));
|
||||
|
|
@ -371,7 +310,6 @@ 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>
|
||||
|
||||
|
|
@ -387,7 +325,7 @@ pub fn InventoryPopup(
|
|||
is_guest=is_guest
|
||||
acquire_endpoint="/api/server/inventory/request"
|
||||
on_acquired=Callback::new(move |_| {
|
||||
// Trigger inventory refresh
|
||||
// Trigger inventory refresh and reset loaded state
|
||||
set_my_inventory_loaded.set(false);
|
||||
set_inventory_refresh_trigger.update(|n| *n += 1);
|
||||
})
|
||||
|
|
@ -407,7 +345,7 @@ pub fn InventoryPopup(
|
|||
acquire_endpoint_is_realm=true
|
||||
realm_slug=realm_slug
|
||||
on_acquired=Callback::new(move |_| {
|
||||
// Trigger inventory refresh
|
||||
// Trigger inventory refresh and reset loaded state
|
||||
set_my_inventory_loaded.set(false);
|
||||
set_inventory_refresh_trigger.update(|n| *n += 1);
|
||||
})
|
||||
|
|
@ -455,79 +393,8 @@ 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">
|
||||
|
|
@ -644,11 +511,7 @@ 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 {
|
||||
view! { "Dropping..." }.into_any()
|
||||
} else {
|
||||
view! { <span><u>"D"</u>"rop"</span> }.into_any()
|
||||
}}
|
||||
{if is_dropping { "Dropping..." } else { "Drop" }}
|
||||
</button>
|
||||
// Delete button - only shown for droppable props
|
||||
<Show when=move || is_droppable>
|
||||
|
|
@ -683,7 +546,6 @@ fn MyInventoryTab(
|
|||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -752,37 +614,12 @@ fn AcquisitionPropsTab(
|
|||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
// 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)
|
||||
// Update local state to mark prop as owned
|
||||
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(());
|
||||
}
|
||||
|
|
@ -810,31 +647,7 @@ 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">
|
||||
|
|
@ -890,20 +703,18 @@ fn AcquisitionPropsTab(
|
|||
format!("/static/{}", prop.asset_path)
|
||||
};
|
||||
|
||||
// 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)
|
||||
};
|
||||
// Visual indicator for ownership status
|
||||
let user_owns = prop.user_owns;
|
||||
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 overflow-hidden {}",
|
||||
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 relative {}",
|
||||
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"
|
||||
|
|
@ -924,7 +735,7 @@ fn AcquisitionPropsTab(
|
|||
class="w-full h-full object-contain"
|
||||
/>
|
||||
// Ownership badge
|
||||
<Show when=user_owns>
|
||||
<Show when=move || 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>
|
||||
|
|
@ -986,7 +797,6 @@ fn AcquisitionPropsTab(
|
|||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ 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; \
|
||||
|
|
@ -72,8 +79,8 @@ pub fn LoosePropCanvas(
|
|||
z-index: {}; \
|
||||
pointer-events: auto; \
|
||||
width: {}px; \
|
||||
height: {}px;",
|
||||
canvas_x, canvas_y, z_index, prop_size, prop_size
|
||||
height: {}px; {}",
|
||||
canvas_x, canvas_y, z_index, prop_size, prop_size, border_style
|
||||
)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ pub fn RealmSceneViewer(
|
|||
#[prop(optional, into)] on_prop_scale_update: Option<Callback<(Uuid, f32)>>,
|
||||
#[prop(optional, into)] on_prop_move: Option<Callback<(Uuid, f64, f64)>>,
|
||||
#[prop(optional, into)] on_prop_lock_toggle: Option<Callback<(Uuid, bool)>>,
|
||||
#[prop(optional, into)] on_prop_delete: Option<Callback<Uuid>>,
|
||||
) -> impl IntoView {
|
||||
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
||||
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
|
||||
|
|
@ -132,12 +131,12 @@ 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::<String>::None);
|
||||
|
||||
// Click handler for movement (props are now handled via context menu)
|
||||
// Click handler for movement or prop pickup
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_overlay_click = {
|
||||
let on_move = on_move.clone();
|
||||
let on_prop_click = on_prop_click.clone();
|
||||
move |ev: web_sys::MouseEvent| {
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
|
|
@ -165,7 +164,9 @@ pub fn RealmSceneViewer(
|
|||
}
|
||||
}
|
||||
|
||||
if clicked_prop.is_none() {
|
||||
if let Some(prop_id) = clicked_prop {
|
||||
on_prop_click.run(prop_id);
|
||||
} else {
|
||||
let target = ev.current_target().unwrap();
|
||||
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
|
||||
let rect = element.get_bounding_client_rect();
|
||||
|
|
@ -207,23 +208,14 @@ pub fn RealmSceneViewer(
|
|||
if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") {
|
||||
if hit_test_canvas(&canvas, client_x, client_y) {
|
||||
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
|
||||
if let Some(prop) = loose_props.get().iter().find(|p| p.id == prop_id) {
|
||||
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
|
||||
|
||||
// Don't show menu if prop is locked and user is not a moderator
|
||||
if prop.is_locked && !is_mod {
|
||||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
|
||||
ev.prevent_default();
|
||||
set_prop_context_menu_position.set((client_x, client_y));
|
||||
set_prop_context_menu_target.set(Some(prop_id));
|
||||
set_prop_context_menu_open.set(true);
|
||||
|
||||
if let Some(prop) = loose_props.get().iter().find(|p| p.id == prop_id) {
|
||||
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((
|
||||
|
|
@ -601,15 +593,12 @@ pub fn RealmSceneViewer(
|
|||
<ContextMenu
|
||||
open=Signal::derive(move || prop_context_menu_open.get())
|
||||
position=Signal::derive(move || prop_context_menu_position.get())
|
||||
header=Signal::derive(move || {
|
||||
prop_context_name.get().or_else(|| Some("Prop".to_string()))
|
||||
})
|
||||
header=Signal::derive(move || Some("Prop".to_string()))
|
||||
items=Signal::derive(move || {
|
||||
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
|
||||
let is_locked = prop_context_is_locked.get();
|
||||
let mut items = Vec::new();
|
||||
if !is_locked || is_mod {
|
||||
items.push(ContextMenuItem { label: "Pick Up".to_string(), action: "pick_up".to_string() });
|
||||
items.push(ContextMenuItem { label: "Move".to_string(), action: "move".to_string() });
|
||||
}
|
||||
if is_mod {
|
||||
|
|
@ -618,21 +607,13 @@ pub fn RealmSceneViewer(
|
|||
label: if is_locked { "Unlock" } else { "Lock" }.to_string(),
|
||||
action: if is_locked { "unlock" } else { "lock" }.to_string(),
|
||||
});
|
||||
items.push(ContextMenuItem { label: "Delete".to_string(), action: "delete".to_string() });
|
||||
}
|
||||
items
|
||||
})
|
||||
on_select=Callback::new({
|
||||
let on_prop_lock_toggle = on_prop_lock_toggle.clone();
|
||||
let on_prop_click = on_prop_click.clone();
|
||||
let on_prop_delete = on_prop_delete.clone();
|
||||
move |action: String| {
|
||||
match action.as_str() {
|
||||
"pick_up" => {
|
||||
if let Some(prop_id) = prop_context_menu_target.get() {
|
||||
on_prop_click.run(prop_id);
|
||||
}
|
||||
}
|
||||
"set_scale" => {
|
||||
if let Some(prop_id) = prop_context_menu_target.get() {
|
||||
set_scale_mode_prop_id.set(Some(prop_id));
|
||||
|
|
@ -656,13 +637,6 @@ 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);
|
||||
|
|
@ -671,7 +645,6 @@ 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);
|
||||
})
|
||||
/>
|
||||
<ScaleOverlay
|
||||
|
|
|
|||
|
|
@ -182,9 +182,9 @@ pub fn MoveOverlay(
|
|||
let mouse_x = ev.client_x() as f64;
|
||||
let mouse_y = ev.client_y() as f64;
|
||||
|
||||
// Get scene canvas position (the inner container with the actual scene)
|
||||
// Get scene viewer's position
|
||||
let document = web_sys::window().unwrap().document().unwrap();
|
||||
if let Some(viewer) = document.query_selector(".scene-canvas").ok().flatten() {
|
||||
if let Some(viewer) = document.query_selector(".scene-viewer-container").ok().flatten() {
|
||||
let rect = viewer.get_bounding_client_rect();
|
||||
let viewer_x = mouse_x - rect.left();
|
||||
let viewer_y = mouse_y - rect.top();
|
||||
|
|
@ -246,10 +246,10 @@ pub fn MoveOverlay(
|
|||
let ox = offset_x.get();
|
||||
let oy = offset_y.get();
|
||||
|
||||
// Get scene canvas position in viewport (inner container with actual scene)
|
||||
// Get scene viewer position in viewport
|
||||
let document = web_sys::window().unwrap().document().unwrap();
|
||||
let viewer_offset = document
|
||||
.query_selector(".scene-canvas")
|
||||
.query_selector(".scene-viewer-container")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| {
|
||||
|
|
|
|||
|
|
@ -667,10 +667,6 @@ fn handle_server_message(
|
|||
// No scene state change needed
|
||||
PostAction::None
|
||||
}
|
||||
ServerMessage::LoosePropDeleted { prop_id, .. } => {
|
||||
// Treat deleted props the same as picked up (remove from display)
|
||||
PostAction::PropPickedUp(prop_id)
|
||||
}
|
||||
ServerMessage::PropRefresh { prop } => {
|
||||
PostAction::PropRefresh(prop)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1244,16 +1244,6 @@ 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! {
|
||||
<div class="relative w-full">
|
||||
<RealmSceneViewer
|
||||
|
|
@ -1279,7 +1269,6 @@ pub fn RealmPage() -> 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
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||||
<ChatInput
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue