Compare commits

...

4 commits

11 changed files with 505 additions and 70 deletions

View file

@ -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>,

View file

@ -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,

View file

@ -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(())
}

View file

@ -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.

View file

@ -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) => {

View file

@ -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);
}); });
// 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); 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);
}); });
// 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); 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,8 +455,79 @@ 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
node_ref=container_ref
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">
@ -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>
} }
} }

View file

@ -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
) )
}; };

View file

@ -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>() {
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(); ev.prevent_default();
set_prop_context_menu_position.set((client_x, client_y)); set_prop_context_menu_position.set((client_x, client_y));
set_prop_context_menu_target.set(Some(prop_id)); set_prop_context_menu_target.set(Some(prop_id));
set_prop_context_menu_open.set(true); set_prop_context_menu_open.set(true);
if let Some(prop) = loose_props.get().iter().find(|p| p.id == prop_id) {
set_scale_mode_initial_scale.set(prop.scale); set_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

View file

@ -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| {

View file

@ -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)
} }

View file

@ -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