Compare commits
4 commits
98590f63e7
...
4f0f88504a
| Author | SHA256 | Date | |
|---|---|---|---|
| 4f0f88504a | |||
| 475d1ef90a | |||
| 5e14481714 | |||
| 5f543ca6c4 |
11 changed files with 505 additions and 70 deletions
|
|
@ -822,21 +822,81 @@ 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).
|
/// An inventory item (user-owned prop).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
/// Response for inventory list.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct InventoryResponse {
|
pub struct InventoryResponse {
|
||||||
|
|
@ -879,16 +939,69 @@ pub struct PropAcquisitionListResponse {
|
||||||
pub props: Vec<PropAcquisitionInfo>,
|
pub props: Vec<PropAcquisitionInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A prop dropped in a channel, available for pickup.
|
/// Intermediate row type for database queries returning loose props.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
///
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
/// This maps directly to database columns (with nullable server_prop_id/realm_prop_id)
|
||||||
pub struct LooseProp {
|
/// and is converted to `LooseProp` with a `PropSource` enum.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct LoosePropRow {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
pub server_prop_id: Option<Uuid>,
|
pub server_prop_id: Option<Uuid>,
|
||||||
pub realm_prop_id: Option<Uuid>,
|
pub realm_prop_id: Option<Uuid>,
|
||||||
pub position_x: f64,
|
pub position_x: f64,
|
||||||
pub position_y: 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.
|
/// Scale factor (0.1 - 10.0) inherited from prop definition at drop time.
|
||||||
pub scale: f32,
|
pub scale: f32,
|
||||||
pub dropped_by: Option<Uuid>,
|
pub dropped_by: Option<Uuid>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
use sqlx::PgExecutor;
|
use sqlx::PgExecutor;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{InventoryItem, LooseProp};
|
use crate::models::{InventoryItem, LooseProp, LoosePropRow, PropSource};
|
||||||
use chattyness_error::{AppError, OptionExt};
|
use chattyness_error::{AppError, OptionExt};
|
||||||
|
|
||||||
/// Ensure an instance exists for a scene.
|
/// Ensure an instance exists for a scene.
|
||||||
|
|
@ -37,7 +37,7 @@ pub async fn list_channel_loose_props<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
channel_id: Uuid,
|
channel_id: Uuid,
|
||||||
) -> Result<Vec<LooseProp>, AppError> {
|
) -> Result<Vec<LooseProp>, AppError> {
|
||||||
let props = sqlx::query_as::<_, LooseProp>(
|
let rows = sqlx::query_as::<_, LoosePropRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
lp.id,
|
lp.id,
|
||||||
|
|
@ -66,7 +66,7 @@ pub async fn list_channel_loose_props<'e>(
|
||||||
.fetch_all(executor)
|
.fetch_all(executor)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(props)
|
Ok(rows.into_iter().map(LooseProp::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop a prop from inventory to the canvas.
|
/// Drop a prop from inventory to the canvas.
|
||||||
|
|
@ -224,12 +224,22 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
Some(prop_name),
|
Some(prop_name),
|
||||||
Some(prop_asset_path),
|
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.
|
// Success! Convert f32 positions to f64.
|
||||||
Ok(LooseProp {
|
Ok(LooseProp {
|
||||||
id,
|
id,
|
||||||
channel_id,
|
channel_id,
|
||||||
server_prop_id,
|
source,
|
||||||
realm_prop_id,
|
|
||||||
position_x: position_x.into(),
|
position_x: position_x.into(),
|
||||||
position_y: position_y.into(),
|
position_y: position_y.into(),
|
||||||
scale,
|
scale,
|
||||||
|
|
@ -311,17 +321,18 @@ pub async fn pick_up_loose_prop<'e>(
|
||||||
'[]'::jsonb,
|
'[]'::jsonb,
|
||||||
now()
|
now()
|
||||||
FROM source_info si
|
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
|
SELECT
|
||||||
ii.id,
|
ii.id,
|
||||||
|
COALESCE(ii.server_prop_id, ii.realm_prop_id) as prop_id,
|
||||||
ii.prop_name,
|
ii.prop_name,
|
||||||
ii.prop_asset_path,
|
ii.prop_asset_path,
|
||||||
ii.layer,
|
ii.layer,
|
||||||
ii.is_transferable,
|
ii.is_transferable,
|
||||||
ii.is_portable,
|
ii.is_portable,
|
||||||
ii.is_droppable,
|
ii.is_droppable,
|
||||||
'server_library'::server.prop_origin as origin,
|
ii.origin,
|
||||||
ii.acquired_at
|
ii.acquired_at
|
||||||
FROM inserted_item ii
|
FROM inserted_item ii
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -351,7 +362,7 @@ pub async fn update_loose_prop_scale<'e>(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let prop = sqlx::query_as::<_, LooseProp>(
|
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||||
r#"
|
r#"
|
||||||
WITH updated AS (
|
WITH updated AS (
|
||||||
UPDATE scene.loose_props
|
UPDATE scene.loose_props
|
||||||
|
|
@ -398,7 +409,7 @@ pub async fn update_loose_prop_scale<'e>(
|
||||||
.await?
|
.await?
|
||||||
.or_not_found("Loose prop (may have expired)")?;
|
.or_not_found("Loose prop (may have expired)")?;
|
||||||
|
|
||||||
Ok(prop)
|
Ok(LooseProp::from(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a loose prop by ID.
|
/// Get a loose prop by ID.
|
||||||
|
|
@ -406,7 +417,7 @@ pub async fn get_loose_prop_by_id<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
loose_prop_id: Uuid,
|
loose_prop_id: Uuid,
|
||||||
) -> Result<Option<LooseProp>, AppError> {
|
) -> Result<Option<LooseProp>, AppError> {
|
||||||
let prop = sqlx::query_as::<_, LooseProp>(
|
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
lp.id,
|
lp.id,
|
||||||
|
|
@ -434,7 +445,7 @@ pub async fn get_loose_prop_by_id<'e>(
|
||||||
.fetch_optional(executor)
|
.fetch_optional(executor)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(prop)
|
Ok(row.map(LooseProp::from))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a loose prop to a new position.
|
/// Move a loose prop to a new position.
|
||||||
|
|
@ -444,7 +455,7 @@ pub async fn move_loose_prop<'e>(
|
||||||
x: f64,
|
x: f64,
|
||||||
y: f64,
|
y: f64,
|
||||||
) -> Result<LooseProp, AppError> {
|
) -> Result<LooseProp, AppError> {
|
||||||
let prop = sqlx::query_as::<_, LooseProp>(
|
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||||
r#"
|
r#"
|
||||||
WITH updated AS (
|
WITH updated AS (
|
||||||
UPDATE scene.loose_props
|
UPDATE scene.loose_props
|
||||||
|
|
@ -492,7 +503,7 @@ pub async fn move_loose_prop<'e>(
|
||||||
.await?
|
.await?
|
||||||
.or_not_found("Loose prop (may have expired)")?;
|
.or_not_found("Loose prop (may have expired)")?;
|
||||||
|
|
||||||
Ok(prop)
|
Ok(LooseProp::from(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lock a loose prop (moderator only).
|
/// Lock a loose prop (moderator only).
|
||||||
|
|
@ -501,7 +512,7 @@ pub async fn lock_loose_prop<'e>(
|
||||||
loose_prop_id: Uuid,
|
loose_prop_id: Uuid,
|
||||||
locked_by: Uuid,
|
locked_by: Uuid,
|
||||||
) -> Result<LooseProp, AppError> {
|
) -> Result<LooseProp, AppError> {
|
||||||
let prop = sqlx::query_as::<_, LooseProp>(
|
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||||
r#"
|
r#"
|
||||||
WITH updated AS (
|
WITH updated AS (
|
||||||
UPDATE scene.loose_props
|
UPDATE scene.loose_props
|
||||||
|
|
@ -548,7 +559,7 @@ pub async fn lock_loose_prop<'e>(
|
||||||
.await?
|
.await?
|
||||||
.or_not_found("Loose prop (may have expired)")?;
|
.or_not_found("Loose prop (may have expired)")?;
|
||||||
|
|
||||||
Ok(prop)
|
Ok(LooseProp::from(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unlock a loose prop (moderator only).
|
/// Unlock a loose prop (moderator only).
|
||||||
|
|
@ -556,7 +567,7 @@ pub async fn unlock_loose_prop<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
loose_prop_id: Uuid,
|
loose_prop_id: Uuid,
|
||||||
) -> Result<LooseProp, AppError> {
|
) -> Result<LooseProp, AppError> {
|
||||||
let prop = sqlx::query_as::<_, LooseProp>(
|
let row = sqlx::query_as::<_, LoosePropRow>(
|
||||||
r#"
|
r#"
|
||||||
WITH updated AS (
|
WITH updated AS (
|
||||||
UPDATE scene.loose_props
|
UPDATE scene.loose_props
|
||||||
|
|
@ -602,7 +613,7 @@ pub async fn unlock_loose_prop<'e>(
|
||||||
.await?
|
.await?
|
||||||
.or_not_found("Loose prop (may have expired)")?;
|
.or_not_found("Loose prop (may have expired)")?;
|
||||||
|
|
||||||
Ok(prop)
|
Ok(LooseProp::from(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete expired loose props.
|
/// Delete expired loose props.
|
||||||
|
|
@ -620,3 +631,27 @@ pub async fn cleanup_expired_props<'e>(executor: impl PgExecutor<'e>) -> Result<
|
||||||
|
|
||||||
Ok(result.rows_affected())
|
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,6 +140,12 @@ pub enum ClientMessage {
|
||||||
/// Inventory item ID to delete.
|
/// Inventory item ID to delete.
|
||||||
inventory_item_id: Uuid,
|
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.
|
/// Server-to-client WebSocket messages.
|
||||||
|
|
@ -263,6 +269,14 @@ pub enum ServerMessage {
|
||||||
inventory_item_id: Uuid,
|
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.
|
/// A prop was updated (scale changed) - clients should update their local copy.
|
||||||
PropRefresh {
|
PropRefresh {
|
||||||
/// The updated prop with all current values.
|
/// The updated prop with all current values.
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
Message::Close(close_frame) => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
set_selected_item.set(None);
|
// 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 {
|
} 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);
|
||||||
});
|
});
|
||||||
set_selected_item.set(None);
|
// 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);
|
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,24 +455,95 @@ 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! {
|
||||||
// Loading state
|
<div
|
||||||
<Show when=move || loading.get()>
|
node_ref=container_ref
|
||||||
<div class="flex items-center justify-center py-12">
|
tabindex="0"
|
||||||
<p class="text-gray-400">"Loading inventory..."</p>
|
on:keydown=handle_keydown
|
||||||
</div>
|
class="outline-none"
|
||||||
</Show>
|
>
|
||||||
|
// Loading state
|
||||||
|
<Show when=move || loading.get()>
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<p class="text-gray-400">"Loading inventory..."</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
// Error state
|
// Error state
|
||||||
<Show when=move || error.get().is_some()>
|
<Show when=move || error.get().is_some()>
|
||||||
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4">
|
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4">
|
||||||
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p>
|
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
<Show when=move || !loading.get() && error.get().is_none() && items.get().is_empty()>
|
<Show when=move || !loading.get() && error.get().is_none() && items.get().is_empty()>
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4">
|
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4">
|
||||||
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,13 +65,6 @@ pub fn LoosePropCanvas(
|
||||||
let canvas_x = screen_x - prop_size / 2.0;
|
let canvas_x = screen_x - prop_size / 2.0;
|
||||||
let canvas_y = screen_y - 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!(
|
format!(
|
||||||
"position: absolute; \
|
"position: absolute; \
|
||||||
left: 0; top: 0; \
|
left: 0; top: 0; \
|
||||||
|
|
@ -79,8 +72,8 @@ pub fn LoosePropCanvas(
|
||||||
z-index: {}; \
|
z-index: {}; \
|
||||||
pointer-events: auto; \
|
pointer-events: auto; \
|
||||||
width: {}px; \
|
width: {}px; \
|
||||||
height: {}px; {}",
|
height: {}px;",
|
||||||
canvas_x, canvas_y, z_index, prop_size, prop_size, border_style
|
canvas_x, canvas_y, z_index, prop_size, prop_size
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ pub fn RealmSceneViewer(
|
||||||
#[prop(optional, into)] on_prop_scale_update: Option<Callback<(Uuid, f32)>>,
|
#[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_move: Option<Callback<(Uuid, f64, f64)>>,
|
||||||
#[prop(optional, into)] on_prop_lock_toggle: Option<Callback<(Uuid, bool)>>,
|
#[prop(optional, into)] on_prop_lock_toggle: Option<Callback<(Uuid, bool)>>,
|
||||||
|
#[prop(optional, into)] on_prop_delete: Option<Callback<Uuid>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
||||||
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
|
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
|
||||||
|
|
@ -131,12 +132,12 @@ pub fn RealmSceneViewer(
|
||||||
let move_mode_preview_position = RwSignal::new((0.0_f64, 0.0_f64));
|
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 (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_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 or prop pickup
|
// Click handler for movement (props are now handled via context menu)
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_overlay_click = {
|
let on_overlay_click = {
|
||||||
let on_move = on_move.clone();
|
let on_move = on_move.clone();
|
||||||
let on_prop_click = on_prop_click.clone();
|
|
||||||
move |ev: web_sys::MouseEvent| {
|
move |ev: web_sys::MouseEvent| {
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
|
@ -164,9 +165,7 @@ pub fn RealmSceneViewer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(prop_id) = clicked_prop {
|
if clicked_prop.is_none() {
|
||||||
on_prop_click.run(prop_id);
|
|
||||||
} else {
|
|
||||||
let target = ev.current_target().unwrap();
|
let target = ev.current_target().unwrap();
|
||||||
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
|
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
|
||||||
let rect = element.get_bounding_client_rect();
|
let rect = element.get_bounding_client_rect();
|
||||||
|
|
@ -208,14 +207,23 @@ pub fn RealmSceneViewer(
|
||||||
if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") {
|
if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") {
|
||||||
if hit_test_canvas(&canvas, client_x, client_y) {
|
if hit_test_canvas(&canvas, client_x, client_y) {
|
||||||
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
|
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
|
||||||
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) {
|
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);
|
||||||
|
|
||||||
set_scale_mode_initial_scale.set(prop.scale);
|
set_scale_mode_initial_scale.set(prop.scale);
|
||||||
set_prop_context_is_locked.set(prop.is_locked);
|
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);
|
set_move_mode_prop_scale.set(prop.scale);
|
||||||
let rect = canvas.get_bounding_client_rect();
|
let rect = canvas.get_bounding_client_rect();
|
||||||
set_scale_mode_prop_center.set((
|
set_scale_mode_prop_center.set((
|
||||||
|
|
@ -593,12 +601,15 @@ pub fn RealmSceneViewer(
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
open=Signal::derive(move || prop_context_menu_open.get())
|
open=Signal::derive(move || prop_context_menu_open.get())
|
||||||
position=Signal::derive(move || prop_context_menu_position.get())
|
position=Signal::derive(move || prop_context_menu_position.get())
|
||||||
header=Signal::derive(move || Some("Prop".to_string()))
|
header=Signal::derive(move || {
|
||||||
|
prop_context_name.get().or_else(|| Some("Prop".to_string()))
|
||||||
|
})
|
||||||
items=Signal::derive(move || {
|
items=Signal::derive(move || {
|
||||||
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
|
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
|
||||||
let is_locked = prop_context_is_locked.get();
|
let is_locked = prop_context_is_locked.get();
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
if !is_locked || is_mod {
|
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() });
|
items.push(ContextMenuItem { label: "Move".to_string(), action: "move".to_string() });
|
||||||
}
|
}
|
||||||
if is_mod {
|
if is_mod {
|
||||||
|
|
@ -607,13 +618,21 @@ pub fn RealmSceneViewer(
|
||||||
label: if is_locked { "Unlock" } else { "Lock" }.to_string(),
|
label: if is_locked { "Unlock" } else { "Lock" }.to_string(),
|
||||||
action: 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
|
items
|
||||||
})
|
})
|
||||||
on_select=Callback::new({
|
on_select=Callback::new({
|
||||||
let on_prop_lock_toggle = on_prop_lock_toggle.clone();
|
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| {
|
move |action: String| {
|
||||||
match action.as_str() {
|
match action.as_str() {
|
||||||
|
"pick_up" => {
|
||||||
|
if let Some(prop_id) = prop_context_menu_target.get() {
|
||||||
|
on_prop_click.run(prop_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
"set_scale" => {
|
"set_scale" => {
|
||||||
if let Some(prop_id) = prop_context_menu_target.get() {
|
if let Some(prop_id) = prop_context_menu_target.get() {
|
||||||
set_scale_mode_prop_id.set(Some(prop_id));
|
set_scale_mode_prop_id.set(Some(prop_id));
|
||||||
|
|
@ -637,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);
|
set_prop_context_menu_open.set(false);
|
||||||
|
|
@ -645,6 +671,7 @@ pub fn RealmSceneViewer(
|
||||||
on_close=Callback::new(move |_: ()| {
|
on_close=Callback::new(move |_: ()| {
|
||||||
set_prop_context_menu_open.set(false);
|
set_prop_context_menu_open.set(false);
|
||||||
set_prop_context_menu_target.set(None);
|
set_prop_context_menu_target.set(None);
|
||||||
|
set_prop_context_name.set(None);
|
||||||
})
|
})
|
||||||
/>
|
/>
|
||||||
<ScaleOverlay
|
<ScaleOverlay
|
||||||
|
|
|
||||||
|
|
@ -182,9 +182,9 @@ pub fn MoveOverlay(
|
||||||
let mouse_x = ev.client_x() as f64;
|
let mouse_x = ev.client_x() as f64;
|
||||||
let mouse_y = ev.client_y() as f64;
|
let mouse_y = ev.client_y() as f64;
|
||||||
|
|
||||||
// Get scene viewer's position
|
// Get scene canvas position (the inner container with the actual scene)
|
||||||
let document = web_sys::window().unwrap().document().unwrap();
|
let document = web_sys::window().unwrap().document().unwrap();
|
||||||
if let Some(viewer) = document.query_selector(".scene-viewer-container").ok().flatten() {
|
if let Some(viewer) = document.query_selector(".scene-canvas").ok().flatten() {
|
||||||
let rect = viewer.get_bounding_client_rect();
|
let rect = viewer.get_bounding_client_rect();
|
||||||
let viewer_x = mouse_x - rect.left();
|
let viewer_x = mouse_x - rect.left();
|
||||||
let viewer_y = mouse_y - rect.top();
|
let viewer_y = mouse_y - rect.top();
|
||||||
|
|
@ -246,10 +246,10 @@ pub fn MoveOverlay(
|
||||||
let ox = offset_x.get();
|
let ox = offset_x.get();
|
||||||
let oy = offset_y.get();
|
let oy = offset_y.get();
|
||||||
|
|
||||||
// Get scene viewer position in viewport
|
// Get scene canvas position in viewport (inner container with actual scene)
|
||||||
let document = web_sys::window().unwrap().document().unwrap();
|
let document = web_sys::window().unwrap().document().unwrap();
|
||||||
let viewer_offset = document
|
let viewer_offset = document
|
||||||
.query_selector(".scene-viewer-container")
|
.query_selector(".scene-canvas")
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
|
|
|
||||||
|
|
@ -667,6 +667,10 @@ fn handle_server_message(
|
||||||
// No scene state change needed
|
// No scene state change needed
|
||||||
PostAction::None
|
PostAction::None
|
||||||
}
|
}
|
||||||
|
ServerMessage::LoosePropDeleted { prop_id, .. } => {
|
||||||
|
// Treat deleted props the same as picked up (remove from display)
|
||||||
|
PostAction::PropPickedUp(prop_id)
|
||||||
|
}
|
||||||
ServerMessage::PropRefresh { prop } => {
|
ServerMessage::PropRefresh { prop } => {
|
||||||
PostAction::PropRefresh(prop)
|
PostAction::PropRefresh(prop)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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! {
|
view! {
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<RealmSceneViewer
|
<RealmSceneViewer
|
||||||
|
|
@ -1269,6 +1279,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
on_prop_scale_update=on_prop_scale_update_cb
|
on_prop_scale_update=on_prop_scale_update_cb
|
||||||
on_prop_move=on_prop_move_cb
|
on_prop_move=on_prop_move_cb
|
||||||
on_prop_lock_toggle=on_prop_lock_toggle_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">
|
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue