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).
|
/// 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 {
|
||||||
|
|
@ -939,69 +879,16 @@ pub struct PropAcquisitionListResponse {
|
||||||
pub props: Vec<PropAcquisitionInfo>,
|
pub props: Vec<PropAcquisitionInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Intermediate row type for database queries returning loose props.
|
/// A prop dropped in a channel, available for pickup.
|
||||||
///
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
/// This maps directly to database columns (with nullable server_prop_id/realm_prop_id)
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
/// and is converted to `LooseProp` with a `PropSource` enum.
|
pub struct LooseProp {
|
||||||
#[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,7 +15,6 @@ 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,
|
||||||
|
|
@ -272,7 +271,6 @@ 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,
|
||||||
|
|
@ -449,7 +447,6 @@ 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, LoosePropRow, PropSource};
|
use crate::models::{InventoryItem, LooseProp};
|
||||||
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 rows = sqlx::query_as::<_, LoosePropRow>(
|
let props = sqlx::query_as::<_, LooseProp>(
|
||||||
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(rows.into_iter().map(LooseProp::from).collect())
|
Ok(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop a prop from inventory to the canvas.
|
/// 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_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,
|
||||||
source,
|
server_prop_id,
|
||||||
|
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,
|
||||||
|
|
@ -321,18 +311,17 @@ pub async fn pick_up_loose_prop<'e>(
|
||||||
'[]'::jsonb,
|
'[]'::jsonb,
|
||||||
now()
|
now()
|
||||||
FROM source_info si
|
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
|
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,
|
||||||
ii.origin,
|
'server_library'::server.prop_origin as origin,
|
||||||
ii.acquired_at
|
ii.acquired_at
|
||||||
FROM inserted_item ii
|
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#"
|
r#"
|
||||||
WITH updated AS (
|
WITH updated AS (
|
||||||
UPDATE scene.loose_props
|
UPDATE scene.loose_props
|
||||||
|
|
@ -409,7 +398,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(LooseProp::from(row))
|
Ok(prop)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a loose prop by ID.
|
/// Get a loose prop by ID.
|
||||||
|
|
@ -417,7 +406,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 row = sqlx::query_as::<_, LoosePropRow>(
|
let prop = sqlx::query_as::<_, LooseProp>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
lp.id,
|
lp.id,
|
||||||
|
|
@ -445,7 +434,7 @@ pub async fn get_loose_prop_by_id<'e>(
|
||||||
.fetch_optional(executor)
|
.fetch_optional(executor)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(row.map(LooseProp::from))
|
Ok(prop)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move a loose prop to a new position.
|
/// Move a loose prop to a new position.
|
||||||
|
|
@ -455,7 +444,7 @@ pub async fn move_loose_prop<'e>(
|
||||||
x: f64,
|
x: f64,
|
||||||
y: f64,
|
y: f64,
|
||||||
) -> Result<LooseProp, AppError> {
|
) -> Result<LooseProp, AppError> {
|
||||||
let row = sqlx::query_as::<_, LoosePropRow>(
|
let prop = sqlx::query_as::<_, LooseProp>(
|
||||||
r#"
|
r#"
|
||||||
WITH updated AS (
|
WITH updated AS (
|
||||||
UPDATE scene.loose_props
|
UPDATE scene.loose_props
|
||||||
|
|
@ -503,7 +492,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(LooseProp::from(row))
|
Ok(prop)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lock a loose prop (moderator only).
|
/// Lock a loose prop (moderator only).
|
||||||
|
|
@ -512,7 +501,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 row = sqlx::query_as::<_, LoosePropRow>(
|
let prop = sqlx::query_as::<_, LooseProp>(
|
||||||
r#"
|
r#"
|
||||||
WITH updated AS (
|
WITH updated AS (
|
||||||
UPDATE scene.loose_props
|
UPDATE scene.loose_props
|
||||||
|
|
@ -559,7 +548,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(LooseProp::from(row))
|
Ok(prop)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unlock a loose prop (moderator only).
|
/// Unlock a loose prop (moderator only).
|
||||||
|
|
@ -567,7 +556,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 row = sqlx::query_as::<_, LoosePropRow>(
|
let prop = sqlx::query_as::<_, LooseProp>(
|
||||||
r#"
|
r#"
|
||||||
WITH updated AS (
|
WITH updated AS (
|
||||||
UPDATE scene.loose_props
|
UPDATE scene.loose_props
|
||||||
|
|
@ -613,7 +602,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(LooseProp::from(row))
|
Ok(prop)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete expired loose props.
|
/// Delete expired loose props.
|
||||||
|
|
@ -631,27 +620,3 @@ 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,12 +140,6 @@ 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.
|
||||||
|
|
@ -269,14 +263,6 @@ 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,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) => {
|
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
|
// Handle drop action via WebSocket
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let handle_drop = {
|
let handle_drop = {
|
||||||
|
|
@ -256,28 +229,11 @@ pub fn InventoryPopup(
|
||||||
send_fn(ClientMessage::DropProp {
|
send_fn(ClientMessage::DropProp {
|
||||||
inventory_item_id: item_id,
|
inventory_item_id: item_id,
|
||||||
});
|
});
|
||||||
// Find item index and update ownership before removing
|
|
||||||
let current_items = items.get();
|
|
||||||
let removed_index = current_items.iter().position(|i| i.id == item_id);
|
|
||||||
if let Some(item) = current_items.iter().find(|i| i.id == item_id).cloned() {
|
|
||||||
update_prop_ownership(&item);
|
|
||||||
}
|
|
||||||
// Optimistically remove from local list
|
// Optimistically remove from local list
|
||||||
set_items.update(|items| {
|
set_items.update(|items| {
|
||||||
items.retain(|i| i.id != item_id);
|
items.retain(|i| i.id != item_id);
|
||||||
});
|
});
|
||||||
// Select the next item (or previous if at end)
|
set_selected_item.set(None);
|
||||||
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()));
|
||||||
}
|
}
|
||||||
|
|
@ -300,28 +256,11 @@ pub fn InventoryPopup(
|
||||||
send_fn(ClientMessage::DeleteProp {
|
send_fn(ClientMessage::DeleteProp {
|
||||||
inventory_item_id: item_id,
|
inventory_item_id: item_id,
|
||||||
});
|
});
|
||||||
// Find item index and update ownership before removing
|
|
||||||
let current_items = items.get();
|
|
||||||
let removed_index = current_items.iter().position(|i| i.id == item_id);
|
|
||||||
if let Some(item) = current_items.iter().find(|i| i.id == item_id).cloned() {
|
|
||||||
update_prop_ownership(&item);
|
|
||||||
}
|
|
||||||
// Optimistically remove from local list
|
// Optimistically remove from local list
|
||||||
set_items.update(|items| {
|
set_items.update(|items| {
|
||||||
items.retain(|i| i.id != item_id);
|
items.retain(|i| i.id != item_id);
|
||||||
});
|
});
|
||||||
// Select the next item (or previous if at end)
|
set_selected_item.set(None);
|
||||||
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()));
|
||||||
|
|
@ -371,7 +310,6 @@ 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>
|
||||||
|
|
||||||
|
|
@ -387,7 +325,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
|
// Trigger inventory refresh and reset loaded state
|
||||||
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);
|
||||||
})
|
})
|
||||||
|
|
@ -407,7 +345,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
|
// Trigger inventory refresh and reset loaded state
|
||||||
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);
|
||||||
})
|
})
|
||||||
|
|
@ -455,95 +393,24 @@ fn MyInventoryTab(
|
||||||
#[prop(into)] on_drop: Callback<Uuid>,
|
#[prop(into)] on_drop: Callback<Uuid>,
|
||||||
#[prop(into)] deleting: Signal<bool>,
|
#[prop(into)] deleting: Signal<bool>,
|
||||||
#[prop(into)] on_delete_request: Callback<(Uuid, String)>,
|
#[prop(into)] on_delete_request: Callback<(Uuid, String)>,
|
||||||
/// Callback for immediate delete (Shift+Delete, no confirmation)
|
|
||||||
#[prop(into)] on_delete_immediate: Callback<Uuid>,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
// NodeRef to maintain focus on container after item removal
|
|
||||||
let container_ref = NodeRef::<leptos::html::Div>::new();
|
|
||||||
|
|
||||||
// Refocus container when selected item changes (after drop/delete)
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
Effect::new(move |prev_selected: Option<Option<Uuid>>| {
|
|
||||||
let current = selected_item.get();
|
|
||||||
// If selection changed and we have a new selection, refocus container
|
|
||||||
if prev_selected.is_some() && current.is_some() {
|
|
||||||
if let Some(el) = container_ref.get() {
|
|
||||||
let _ = el.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keyboard shortcut handler
|
|
||||||
let handle_keydown = move |ev: leptos::web_sys::KeyboardEvent| {
|
|
||||||
// Don't handle if in an input field
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
{
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
if let Some(target) = ev.target() {
|
|
||||||
if let Ok(element) = target.dyn_into::<leptos::web_sys::HtmlElement>() {
|
|
||||||
let tag = element.tag_name().to_lowercase();
|
|
||||||
if tag == "input" || tag == "textarea" {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get selected item info
|
|
||||||
let Some(item_id) = selected_item.get() else { return };
|
|
||||||
let Some(item) = items.get().into_iter().find(|i| i.id == item_id) else { return };
|
|
||||||
|
|
||||||
// Only allow actions on droppable items
|
|
||||||
if !item.is_droppable {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = ev.key();
|
|
||||||
let shift = ev.shift_key();
|
|
||||||
|
|
||||||
match key.as_str() {
|
|
||||||
"d" | "D" => {
|
|
||||||
ev.prevent_default();
|
|
||||||
on_drop.run(item_id);
|
|
||||||
}
|
|
||||||
"Delete" => {
|
|
||||||
ev.prevent_default();
|
|
||||||
if shift {
|
|
||||||
// Shift+Delete: immediate delete without confirmation
|
|
||||||
on_delete_immediate.run(item_id);
|
|
||||||
} else {
|
|
||||||
// Delete: delete with confirmation
|
|
||||||
on_delete_request.run((item_id, item.prop_name.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
// Loading state
|
||||||
node_ref=container_ref
|
<Show when=move || loading.get()>
|
||||||
tabindex="0"
|
<div class="flex items-center justify-center py-12">
|
||||||
on:keydown=handle_keydown
|
<p class="text-gray-400">"Loading inventory..."</p>
|
||||||
class="outline-none"
|
</div>
|
||||||
>
|
</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">
|
||||||
|
|
@ -644,11 +511,7 @@ 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 {
|
{if is_dropping { "Dropping..." } else { "Drop" }}
|
||||||
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>
|
||||||
|
|
@ -683,7 +546,6 @@ fn MyInventoryTab(
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -752,37 +614,12 @@ fn AcquisitionPropsTab(
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => {
|
Ok(resp) if resp.ok() => {
|
||||||
// Find the index of the acquired prop before updating
|
// Update local state to mark prop as owned
|
||||||
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(());
|
||||||
}
|
}
|
||||||
|
|
@ -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! {
|
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">
|
||||||
|
|
@ -890,20 +703,18 @@ fn AcquisitionPropsTab(
|
||||||
format!("/static/{}", prop.asset_path)
|
format!("/static/{}", prop.asset_path)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reactive lookup for ownership status (updates when props signal changes)
|
// Visual indicator for ownership status
|
||||||
let user_owns = move || {
|
let user_owns = prop.user_owns;
|
||||||
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 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() {
|
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"
|
||||||
|
|
@ -924,7 +735,7 @@ fn AcquisitionPropsTab(
|
||||||
class="w-full h-full object-contain"
|
class="w-full h-full object-contain"
|
||||||
/>
|
/>
|
||||||
// Ownership badge
|
// 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" />
|
<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>
|
||||||
|
|
@ -986,7 +797,6 @@ fn AcquisitionPropsTab(
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,13 @@ 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; \
|
||||||
|
|
@ -72,8 +79,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
|
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_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));
|
||||||
|
|
@ -132,12 +131,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 (props are now handled via context menu)
|
// Click handler for movement or prop pickup
|
||||||
#[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;
|
||||||
|
|
||||||
|
|
@ -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 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();
|
||||||
|
|
@ -207,23 +208,14 @@ 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((
|
||||||
|
|
@ -601,15 +593,12 @@ 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 || {
|
header=Signal::derive(move || Some("Prop".to_string()))
|
||||||
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 {
|
||||||
|
|
@ -618,21 +607,13 @@ 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));
|
||||||
|
|
@ -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);
|
set_prop_context_menu_open.set(false);
|
||||||
|
|
@ -671,7 +645,6 @@ 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 canvas position (the inner container with the actual scene)
|
// Get scene viewer's position
|
||||||
let document = web_sys::window().unwrap().document().unwrap();
|
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 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 canvas position in viewport (inner container with actual scene)
|
// Get scene viewer position in viewport
|
||||||
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-canvas")
|
.query_selector(".scene-viewer-container")
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
|
|
|
||||||
|
|
@ -667,10 +667,6 @@ 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,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! {
|
view! {
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<RealmSceneViewer
|
<RealmSceneViewer
|
||||||
|
|
@ -1279,7 +1269,6 @@ 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