Initial copy of business card prop with profile
This commit is contained in:
parent
4f0f88504a
commit
9541fb1927
16 changed files with 1193 additions and 71 deletions
|
|
@ -7,7 +7,7 @@ edition.workspace = true
|
|||
chattyness-error = { workspace = true, optional = true }
|
||||
chattyness-shared = { workspace = true, optional = true }
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, optional = true }
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
|
|
@ -18,4 +18,4 @@ rand = { workspace = true, optional = true }
|
|||
|
||||
[features]
|
||||
default = []
|
||||
ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared", "dep:serde_json"]
|
||||
ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared"]
|
||||
|
|
|
|||
|
|
@ -866,6 +866,125 @@ impl From<&PropSource> for PropOrigin {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State types for inventory item state management
|
||||
// ============================================================================
|
||||
|
||||
/// Scope of state visibility for inventory items.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StateScope {
|
||||
/// Server-wide scope: visible to everyone.
|
||||
Server,
|
||||
/// Realm scope: visible only to realm members.
|
||||
Realm,
|
||||
/// User scope: visible to those inspecting the item.
|
||||
User,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StateScope {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
StateScope::Server => write!(f, "server"),
|
||||
StateScope::Realm => write!(f, "realm"),
|
||||
StateScope::User => write!(f, "user"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visibility of state: public or private.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StateVisibility {
|
||||
/// Public state: persists through transfer, visible based on scope.
|
||||
Public,
|
||||
/// Private state: cleared on transfer, only visible to owner.
|
||||
Private,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StateVisibility {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
StateVisibility::Public => write!(f, "public"),
|
||||
StateVisibility::Private => write!(f, "private"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hint for how to render/handle a prop's state (detected from state content).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PropActionHint {
|
||||
/// Render as a business card with profile info and social links.
|
||||
BusinessCard,
|
||||
/// Render as an info panel with formatted JSON.
|
||||
InfoPanel,
|
||||
/// Render as a list of external links/buttons.
|
||||
ExternalLinks,
|
||||
}
|
||||
|
||||
/// View of prop state filtered by viewer permissions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PropStateView {
|
||||
/// The prop ID (inventory item or loose prop).
|
||||
pub prop_id: Uuid,
|
||||
/// The prop name.
|
||||
pub prop_name: String,
|
||||
/// Display name of the owner (if known).
|
||||
pub owner_display_name: Option<String>,
|
||||
/// Server-scoped public state (visible to everyone).
|
||||
pub server_state: serde_json::Value,
|
||||
/// Realm-scoped public state (None if viewer not in same realm).
|
||||
pub realm_state: Option<serde_json::Value>,
|
||||
/// User-scoped public state (visible when inspecting).
|
||||
pub user_state: serde_json::Value,
|
||||
/// Private state bundle (only included if viewer is owner).
|
||||
pub private_state: Option<PrivateStateBundle>,
|
||||
/// Detected action hint based on state content.
|
||||
pub action_hint: Option<PropActionHint>,
|
||||
}
|
||||
|
||||
/// Bundle of private state (only visible to owner).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrivateStateBundle {
|
||||
pub server_private_state: serde_json::Value,
|
||||
pub realm_private_state: serde_json::Value,
|
||||
pub user_private_state: serde_json::Value,
|
||||
}
|
||||
|
||||
impl PropStateView {
|
||||
/// Detect action hint from state content.
|
||||
pub fn detect_action_hint(server_state: &serde_json::Value) -> Option<PropActionHint> {
|
||||
use serde_json::Value;
|
||||
|
||||
// Check for business card pattern (profile with social links)
|
||||
if let Some(profile) = server_state.get("profile") {
|
||||
let profile: &Value = profile;
|
||||
if profile.get("linkedin").is_some()
|
||||
|| profile.get("github").is_some()
|
||||
|| profile.get("twitter").is_some()
|
||||
|| profile.get("website").is_some()
|
||||
{
|
||||
return Some(PropActionHint::BusinessCard);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for external links pattern
|
||||
if server_state.get("links").is_some() {
|
||||
return Some(PropActionHint::ExternalLinks);
|
||||
}
|
||||
|
||||
// If there's any non-empty state, show as info panel
|
||||
if let Some(obj) = server_state.as_object() {
|
||||
if !obj.is_empty() {
|
||||
return Some(PropActionHint::InfoPanel);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An inventory item (user-owned prop).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
|
|
@ -882,6 +1001,28 @@ pub struct InventoryItem {
|
|||
/// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload)
|
||||
pub origin: PropOrigin,
|
||||
pub acquired_at: DateTime<Utc>,
|
||||
|
||||
// Public state columns (persist through transfer)
|
||||
/// Server-scoped state visible to everyone.
|
||||
#[serde(default)]
|
||||
pub server_state: serde_json::Value,
|
||||
/// Realm-scoped state visible to realm members.
|
||||
#[serde(default)]
|
||||
pub realm_state: serde_json::Value,
|
||||
/// User-scoped state visible to item inspectors.
|
||||
#[serde(default)]
|
||||
pub user_state: serde_json::Value,
|
||||
|
||||
// Private state columns (cleared on transfer)
|
||||
/// Private server-scoped state, only visible to owner.
|
||||
#[serde(default)]
|
||||
pub server_private_state: serde_json::Value,
|
||||
/// Private realm-scoped state, only visible to owner.
|
||||
#[serde(default)]
|
||||
pub realm_private_state: serde_json::Value,
|
||||
/// Private user-scoped state, only visible to owner.
|
||||
#[serde(default)]
|
||||
pub user_private_state: serde_json::Value,
|
||||
}
|
||||
|
||||
impl InventoryItem {
|
||||
|
|
@ -960,6 +1101,10 @@ pub struct LoosePropRow {
|
|||
pub prop_asset_path: String,
|
||||
pub is_locked: bool,
|
||||
pub locked_by: Option<Uuid>,
|
||||
// Public state columns (loose props only have public state)
|
||||
pub server_state: serde_json::Value,
|
||||
pub realm_state: serde_json::Value,
|
||||
pub user_state: serde_json::Value,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
|
|
@ -989,6 +1134,9 @@ impl From<LoosePropRow> for LooseProp {
|
|||
prop_asset_path: row.prop_asset_path,
|
||||
is_locked: row.is_locked,
|
||||
locked_by: row.locked_by,
|
||||
server_state: row.server_state,
|
||||
realm_state: row.realm_state,
|
||||
user_state: row.user_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1017,6 +1165,16 @@ pub struct LooseProp {
|
|||
/// User ID of the moderator who locked this prop.
|
||||
#[serde(default)]
|
||||
pub locked_by: Option<Uuid>,
|
||||
// Public state columns (loose props only have public state, private is cleared on drop)
|
||||
/// Server-scoped state visible to everyone.
|
||||
#[serde(default)]
|
||||
pub server_state: serde_json::Value,
|
||||
/// Realm-scoped state visible to realm members.
|
||||
#[serde(default)]
|
||||
pub realm_state: serde_json::Value,
|
||||
/// User-scoped state from previous owner.
|
||||
#[serde(default)]
|
||||
pub user_state: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A server-wide prop (global library).
|
||||
|
|
|
|||
|
|
@ -23,7 +23,13 @@ pub async fn list_user_inventory<'e>(
|
|||
is_portable,
|
||||
is_droppable,
|
||||
origin,
|
||||
acquired_at
|
||||
acquired_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
FROM auth.inventory
|
||||
WHERE user_id = $1
|
||||
ORDER BY acquired_at DESC
|
||||
|
|
@ -251,7 +257,13 @@ pub async fn acquire_server_prop<'e>(
|
|||
origin,
|
||||
is_transferable,
|
||||
is_portable,
|
||||
is_droppable
|
||||
is_droppable,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT
|
||||
$2,
|
||||
|
|
@ -262,7 +274,13 @@ pub async fn acquire_server_prop<'e>(
|
|||
'server_library'::server.prop_origin,
|
||||
oc.is_transferable,
|
||||
oc.is_portable,
|
||||
oc.is_droppable
|
||||
oc.is_droppable,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb
|
||||
FROM ownership_check oc
|
||||
WHERE oc.is_active = true
|
||||
AND oc.is_public = true
|
||||
|
|
@ -280,7 +298,13 @@ pub async fn acquire_server_prop<'e>(
|
|||
is_portable,
|
||||
is_droppable,
|
||||
origin,
|
||||
acquired_at
|
||||
acquired_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT * FROM inserted
|
||||
"#,
|
||||
|
|
@ -428,7 +452,13 @@ pub async fn acquire_realm_prop<'e>(
|
|||
origin,
|
||||
is_transferable,
|
||||
is_portable,
|
||||
is_droppable
|
||||
is_droppable,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT
|
||||
$3,
|
||||
|
|
@ -439,7 +469,13 @@ pub async fn acquire_realm_prop<'e>(
|
|||
'realm_library'::server.prop_origin,
|
||||
oc.is_transferable,
|
||||
true, -- realm props are portable by default
|
||||
oc.is_droppable
|
||||
oc.is_droppable,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb
|
||||
FROM ownership_check oc
|
||||
WHERE oc.is_active = true
|
||||
AND oc.is_public = true
|
||||
|
|
@ -457,7 +493,13 @@ pub async fn acquire_realm_prop<'e>(
|
|||
is_portable,
|
||||
is_droppable,
|
||||
origin,
|
||||
acquired_at
|
||||
acquired_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT * FROM inserted
|
||||
"#,
|
||||
|
|
@ -547,3 +589,103 @@ pub async fn get_realm_prop_acquisition_error<'e>(
|
|||
)),
|
||||
}
|
||||
}
|
||||
|
||||
use crate::models::{StateScope, StateVisibility};
|
||||
|
||||
/// Update state on an inventory item.
|
||||
///
|
||||
/// Only the item owner can update state.
|
||||
/// If `merge` is true, the new state is deep-merged with existing state.
|
||||
/// If `merge` is false, the new state replaces existing state.
|
||||
///
|
||||
/// Returns the updated state value.
|
||||
pub async fn update_inventory_item_state<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
item_id: Uuid,
|
||||
scope: StateScope,
|
||||
visibility: StateVisibility,
|
||||
state: serde_json::Value,
|
||||
merge: bool,
|
||||
) -> Result<serde_json::Value, AppError> {
|
||||
// Determine which column to update based on scope and visibility
|
||||
let column_name = match (scope, visibility) {
|
||||
(StateScope::Server, StateVisibility::Public) => "server_state",
|
||||
(StateScope::Server, StateVisibility::Private) => "server_private_state",
|
||||
(StateScope::Realm, StateVisibility::Public) => "realm_state",
|
||||
(StateScope::Realm, StateVisibility::Private) => "realm_private_state",
|
||||
(StateScope::User, StateVisibility::Public) => "user_state",
|
||||
(StateScope::User, StateVisibility::Private) => "user_private_state",
|
||||
};
|
||||
|
||||
// Build the update expression based on merge flag
|
||||
// If merge is true, use jsonb_strip_nulls(old || new) for deep merge
|
||||
// If merge is false, just replace with new value
|
||||
let update_expr = if merge {
|
||||
format!("{} = jsonb_strip_nulls({} || $3)", column_name, column_name)
|
||||
} else {
|
||||
format!("{} = $3", column_name)
|
||||
};
|
||||
|
||||
// Build the full query
|
||||
let query = format!(
|
||||
r#"
|
||||
UPDATE auth.inventory
|
||||
SET {}
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING {}
|
||||
"#,
|
||||
update_expr, column_name
|
||||
);
|
||||
|
||||
let result: Option<(serde_json::Value,)> = sqlx::query_as(&query)
|
||||
.bind(item_id)
|
||||
.bind(user_id)
|
||||
.bind(&state)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
match result {
|
||||
Some((new_state,)) => Ok(new_state),
|
||||
None => Err(AppError::NotFound(
|
||||
"Inventory item not found or not owned by user".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an inventory item by ID with ownership verification.
|
||||
pub async fn get_inventory_item<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
item_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<InventoryItem>, AppError> {
|
||||
let item = sqlx::query_as::<_, InventoryItem>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(server_prop_id, realm_prop_id) as prop_id,
|
||||
prop_name,
|
||||
prop_asset_path,
|
||||
layer,
|
||||
is_transferable,
|
||||
is_portable,
|
||||
is_droppable,
|
||||
origin,
|
||||
acquired_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
FROM auth.inventory
|
||||
WHERE id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(item_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,10 @@ pub async fn list_channel_loose_props<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
lp.is_locked,
|
||||
lp.locked_by
|
||||
lp.locked_by,
|
||||
lp.server_state,
|
||||
lp.realm_state,
|
||||
lp.user_state
|
||||
FROM scene.loose_props lp
|
||||
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id
|
||||
|
|
@ -69,11 +72,37 @@ pub async fn list_channel_loose_props<'e>(
|
|||
Ok(rows.into_iter().map(LooseProp::from).collect())
|
||||
}
|
||||
|
||||
/// Result row type for drop_prop_to_canvas query.
|
||||
/// Uses a struct instead of tuple because sqlx has a limit on tuple size.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct DropPropResult {
|
||||
item_existed: bool,
|
||||
was_droppable: bool,
|
||||
was_deleted: bool,
|
||||
id: Option<Uuid>,
|
||||
channel_id: Option<Uuid>,
|
||||
server_prop_id: Option<Uuid>,
|
||||
realm_prop_id: Option<Uuid>,
|
||||
position_x: Option<f32>,
|
||||
position_y: Option<f32>,
|
||||
scale: Option<f32>,
|
||||
dropped_by: Option<Uuid>,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
prop_name: Option<String>,
|
||||
prop_asset_path: Option<String>,
|
||||
server_state: Option<serde_json::Value>,
|
||||
realm_state: Option<serde_json::Value>,
|
||||
user_state: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Drop a prop from inventory to the canvas.
|
||||
///
|
||||
/// Deletes from inventory and inserts into loose_props with 30-minute expiry.
|
||||
/// Returns the created loose prop.
|
||||
/// Returns an error if the prop is non-droppable (essential prop).
|
||||
/// Note: Public state (server_state, realm_state, user_state) is transferred to the loose prop.
|
||||
/// Private state is NOT transferred (cleared when dropped).
|
||||
pub async fn drop_prop_to_canvas<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
inventory_item_id: Uuid,
|
||||
|
|
@ -85,23 +114,8 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
// Single CTE that checks existence/droppability and performs the operation atomically.
|
||||
// Returns status flags plus the LooseProp data (if successful).
|
||||
// Includes scale inherited from the source prop's default_scale.
|
||||
let result: Option<(
|
||||
bool,
|
||||
bool,
|
||||
bool,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
Option<f32>,
|
||||
Option<f32>,
|
||||
Option<f32>,
|
||||
Option<Uuid>,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
// Transfers public state columns (server_state, realm_state, user_state).
|
||||
let result: Option<DropPropResult> = sqlx::query_as(
|
||||
r#"
|
||||
WITH item_info AS (
|
||||
SELECT
|
||||
|
|
@ -111,6 +125,9 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
inv.realm_prop_id,
|
||||
inv.prop_name,
|
||||
inv.prop_asset_path,
|
||||
inv.server_state,
|
||||
inv.realm_state,
|
||||
inv.user_state,
|
||||
COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale
|
||||
FROM auth.inventory inv
|
||||
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||
|
|
@ -120,7 +137,8 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
deleted_item AS (
|
||||
DELETE FROM auth.inventory
|
||||
WHERE id = $1 AND user_id = $2 AND is_droppable = true
|
||||
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
||||
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path,
|
||||
server_state, realm_state, user_state
|
||||
),
|
||||
inserted_prop AS (
|
||||
INSERT INTO scene.loose_props (
|
||||
|
|
@ -130,7 +148,10 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
position,
|
||||
scale,
|
||||
dropped_by,
|
||||
expires_at
|
||||
expires_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
$3,
|
||||
|
|
@ -139,7 +160,10 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
public.make_virtual_point($4::real, $5::real),
|
||||
(SELECT default_scale FROM item_info),
|
||||
$2,
|
||||
now() + interval '30 minutes'
|
||||
now() + interval '30 minutes',
|
||||
di.server_state,
|
||||
di.realm_state,
|
||||
di.user_state
|
||||
FROM deleted_item di
|
||||
RETURNING
|
||||
id,
|
||||
|
|
@ -151,7 +175,10 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
scale,
|
||||
dropped_by,
|
||||
expires_at,
|
||||
created_at
|
||||
created_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
EXISTS(SELECT 1 FROM item_info) AS item_existed,
|
||||
|
|
@ -168,7 +195,10 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
ip.expires_at,
|
||||
ip.created_at,
|
||||
di.prop_name,
|
||||
di.prop_asset_path
|
||||
di.prop_asset_path,
|
||||
ip.server_state,
|
||||
ip.realm_state,
|
||||
ip.user_state
|
||||
FROM (SELECT 1) AS dummy
|
||||
LEFT JOIN inserted_prop ip ON true
|
||||
LEFT JOIN deleted_item di ON true
|
||||
|
|
@ -189,41 +219,44 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
"Unexpected error dropping prop to canvas".to_string(),
|
||||
))
|
||||
}
|
||||
Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||
Some(r) if !r.item_existed => {
|
||||
// Item didn't exist
|
||||
Err(AppError::NotFound(
|
||||
"Inventory item not found or not owned by user".to_string(),
|
||||
))
|
||||
}
|
||||
Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||
Some(r) if r.item_existed && !r.was_droppable => {
|
||||
// Item existed but is not droppable
|
||||
Err(AppError::Forbidden(
|
||||
"This prop cannot be dropped - it is an essential prop".to_string(),
|
||||
))
|
||||
}
|
||||
Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||
Some(r) if r.item_existed && r.was_droppable && !r.was_deleted => {
|
||||
// Item was droppable but delete failed (shouldn't happen)
|
||||
Err(AppError::Internal(
|
||||
"Unexpected error dropping prop to canvas".to_string(),
|
||||
))
|
||||
}
|
||||
Some((
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Some(id),
|
||||
Some(channel_id),
|
||||
Some(DropPropResult {
|
||||
item_existed: true,
|
||||
was_droppable: true,
|
||||
was_deleted: true,
|
||||
id: Some(id),
|
||||
channel_id: Some(channel_id),
|
||||
server_prop_id,
|
||||
realm_prop_id,
|
||||
Some(position_x),
|
||||
Some(position_y),
|
||||
Some(scale),
|
||||
position_x: Some(position_x),
|
||||
position_y: Some(position_y),
|
||||
scale: Some(scale),
|
||||
dropped_by,
|
||||
Some(expires_at),
|
||||
Some(created_at),
|
||||
Some(prop_name),
|
||||
Some(prop_asset_path),
|
||||
)) => {
|
||||
expires_at: Some(expires_at),
|
||||
created_at: Some(created_at),
|
||||
prop_name: Some(prop_name),
|
||||
prop_asset_path: Some(prop_asset_path),
|
||||
server_state: Some(server_state),
|
||||
realm_state: Some(realm_state),
|
||||
user_state: Some(user_state),
|
||||
}) => {
|
||||
// Construct PropSource from the nullable columns
|
||||
let source = if let Some(sid) = server_prop_id {
|
||||
PropSource::Server(sid)
|
||||
|
|
@ -250,6 +283,9 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
prop_asset_path,
|
||||
is_locked: false,
|
||||
locked_by: None,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
|
|
@ -264,19 +300,23 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
/// Pick up a loose prop (delete from loose_props, insert to inventory).
|
||||
///
|
||||
/// Returns the created inventory item.
|
||||
/// Note: Public state (server_state, realm_state, user_state) is transferred from the loose prop.
|
||||
/// Private state is initialized to empty (cleared for new owner).
|
||||
pub async fn pick_up_loose_prop<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
loose_prop_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<InventoryItem, AppError> {
|
||||
// Use a CTE to delete from loose_props and insert to inventory
|
||||
// Public state is transferred, private state is initialized to empty
|
||||
let item = sqlx::query_as::<_, InventoryItem>(
|
||||
r#"
|
||||
WITH deleted_prop AS (
|
||||
DELETE FROM scene.loose_props
|
||||
WHERE id = $1
|
||||
AND (expires_at IS NULL OR expires_at > now())
|
||||
RETURNING id, server_prop_id, realm_prop_id
|
||||
RETURNING id, server_prop_id, realm_prop_id,
|
||||
server_state, realm_state, user_state
|
||||
),
|
||||
source_info AS (
|
||||
SELECT
|
||||
|
|
@ -287,7 +327,10 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
COALESCE(sp.is_portable, true) as is_portable,
|
||||
COALESCE(sp.is_droppable, rp.is_droppable, true) as is_droppable,
|
||||
dp.server_prop_id,
|
||||
dp.realm_prop_id
|
||||
dp.realm_prop_id,
|
||||
dp.server_state,
|
||||
dp.realm_state,
|
||||
dp.user_state
|
||||
FROM deleted_prop dp
|
||||
LEFT JOIN server.props sp ON dp.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON dp.realm_prop_id = rp.id
|
||||
|
|
@ -305,7 +348,15 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
is_portable,
|
||||
is_droppable,
|
||||
provenance,
|
||||
acquired_at
|
||||
acquired_at,
|
||||
-- Transfer public state from loose prop
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
-- Initialize private state to empty (cleared for new owner)
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT
|
||||
$2,
|
||||
|
|
@ -319,9 +370,20 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
COALESCE(si.is_portable, true),
|
||||
COALESCE(si.is_droppable, true),
|
||||
'[]'::jsonb,
|
||||
now()
|
||||
now(),
|
||||
-- Transfer public state
|
||||
si.server_state,
|
||||
si.realm_state,
|
||||
si.user_state,
|
||||
-- Private state cleared for new owner
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb
|
||||
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, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer,
|
||||
is_transferable, is_portable, is_droppable, origin, acquired_at,
|
||||
server_state, realm_state, user_state,
|
||||
server_private_state, realm_private_state, user_private_state
|
||||
)
|
||||
SELECT
|
||||
ii.id,
|
||||
|
|
@ -333,7 +395,13 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
ii.is_portable,
|
||||
ii.is_droppable,
|
||||
ii.origin,
|
||||
ii.acquired_at
|
||||
ii.acquired_at,
|
||||
ii.server_state,
|
||||
ii.realm_state,
|
||||
ii.user_state,
|
||||
ii.server_private_state,
|
||||
ii.realm_private_state,
|
||||
ii.user_private_state
|
||||
FROM inserted_item ii
|
||||
"#,
|
||||
)
|
||||
|
|
@ -381,7 +449,10 @@ pub async fn update_loose_prop_scale<'e>(
|
|||
expires_at,
|
||||
created_at,
|
||||
is_locked,
|
||||
locked_by
|
||||
locked_by,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
|
|
@ -397,7 +468,10 @@ pub async fn update_loose_prop_scale<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by
|
||||
u.locked_by,
|
||||
u.server_state,
|
||||
u.realm_state,
|
||||
u.user_state
|
||||
FROM updated u
|
||||
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
|
||||
|
|
@ -433,7 +507,10 @@ pub async fn get_loose_prop_by_id<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
lp.is_locked,
|
||||
lp.locked_by
|
||||
lp.locked_by,
|
||||
lp.server_state,
|
||||
lp.realm_state,
|
||||
lp.user_state
|
||||
FROM scene.loose_props lp
|
||||
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id
|
||||
|
|
@ -474,7 +551,10 @@ pub async fn move_loose_prop<'e>(
|
|||
expires_at,
|
||||
created_at,
|
||||
is_locked,
|
||||
locked_by
|
||||
locked_by,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
|
|
@ -490,7 +570,10 @@ pub async fn move_loose_prop<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by
|
||||
u.locked_by,
|
||||
u.server_state,
|
||||
u.realm_state,
|
||||
u.user_state
|
||||
FROM updated u
|
||||
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
|
||||
|
|
@ -531,7 +614,10 @@ pub async fn lock_loose_prop<'e>(
|
|||
expires_at,
|
||||
created_at,
|
||||
is_locked,
|
||||
locked_by
|
||||
locked_by,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
|
|
@ -547,7 +633,10 @@ pub async fn lock_loose_prop<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by
|
||||
u.locked_by,
|
||||
u.server_state,
|
||||
u.realm_state,
|
||||
u.user_state
|
||||
FROM updated u
|
||||
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
|
||||
|
|
@ -586,7 +675,10 @@ pub async fn unlock_loose_prop<'e>(
|
|||
expires_at,
|
||||
created_at,
|
||||
is_locked,
|
||||
locked_by
|
||||
locked_by,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
|
|
@ -602,7 +694,10 @@ pub async fn unlock_loose_prop<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by
|
||||
u.locked_by,
|
||||
u.server_state,
|
||||
u.realm_state,
|
||||
u.user_state
|
||||
FROM updated u
|
||||
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp};
|
||||
use crate::models::{
|
||||
AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp,
|
||||
PropStateView, StateScope, StateVisibility,
|
||||
};
|
||||
|
||||
/// Default function for serde that returns true (for is_same_scene field).
|
||||
/// Must be pub for serde derive macro to access via full path.
|
||||
|
|
@ -146,6 +149,29 @@ pub enum ClientMessage {
|
|||
/// The loose prop ID to delete.
|
||||
loose_prop_id: Uuid,
|
||||
},
|
||||
|
||||
/// Update state on an inventory item.
|
||||
UpdateItemState {
|
||||
/// The inventory item ID to update.
|
||||
inventory_item_id: Uuid,
|
||||
/// Which scope to update (server, realm, or user).
|
||||
scope: StateScope,
|
||||
/// Whether to update public or private state.
|
||||
visibility: StateVisibility,
|
||||
/// The new state value.
|
||||
state: serde_json::Value,
|
||||
/// If true, merge with existing state; if false, replace.
|
||||
#[serde(default)]
|
||||
merge: bool,
|
||||
},
|
||||
|
||||
/// Request to view a prop's state.
|
||||
ViewPropState {
|
||||
/// The prop ID to view (either inventory item or loose prop).
|
||||
prop_id: Uuid,
|
||||
/// Whether this is a loose prop (true) or inventory item (false).
|
||||
is_loose_prop: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Server-to-client WebSocket messages.
|
||||
|
|
@ -348,4 +374,22 @@ pub enum ServerMessage {
|
|||
/// Display name of who cleared the forced avatar (if mod command).
|
||||
cleared_by: Option<String>,
|
||||
},
|
||||
|
||||
/// Response with prop state for viewing.
|
||||
PropStateViewResponse {
|
||||
/// The prop state view data.
|
||||
view: PropStateView,
|
||||
},
|
||||
|
||||
/// Confirmation of item state update.
|
||||
ItemStateUpdated {
|
||||
/// The inventory item ID that was updated.
|
||||
inventory_item_id: Uuid,
|
||||
/// Which scope was updated.
|
||||
scope: StateScope,
|
||||
/// Whether public or private state was updated.
|
||||
visibility: StateVisibility,
|
||||
/// The new state value.
|
||||
new_state: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1703,6 +1703,126 @@ async fn handle_socket(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClientMessage::UpdateItemState {
|
||||
inventory_item_id,
|
||||
scope,
|
||||
visibility,
|
||||
state,
|
||||
merge,
|
||||
} => {
|
||||
// Update state on an inventory item
|
||||
// Need to acquire connection with RLS context set
|
||||
let result = async {
|
||||
let mut conn = pool.acquire().await?;
|
||||
set_rls_user_id(&mut conn, user_id).await?;
|
||||
inventory::update_inventory_item_state(
|
||||
&mut *conn,
|
||||
user_id,
|
||||
inventory_item_id,
|
||||
scope,
|
||||
visibility,
|
||||
state,
|
||||
merge,
|
||||
).await
|
||||
}.await;
|
||||
|
||||
match result {
|
||||
Ok(new_state) => {
|
||||
tracing::debug!(
|
||||
"[WS] User {} updated {}:{} state on item {}",
|
||||
user_id, scope, visibility, inventory_item_id
|
||||
);
|
||||
let _ = direct_tx.send(ServerMessage::ItemStateUpdated {
|
||||
inventory_item_id,
|
||||
scope,
|
||||
visibility,
|
||||
new_state,
|
||||
}).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Update item state failed: {:?}", e);
|
||||
let _ = direct_tx.send(ServerMessage::Error {
|
||||
code: "UPDATE_STATE_FAILED".to_string(),
|
||||
message: format!("{:?}", e),
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClientMessage::ViewPropState { prop_id, is_loose_prop } => {
|
||||
// View state of a prop (loose prop or inventory item)
|
||||
use chattyness_db::models::{PropStateView, PrivateStateBundle};
|
||||
|
||||
let result = if is_loose_prop {
|
||||
// Get loose prop state
|
||||
match loose_props::get_loose_prop_by_id(&pool, prop_id).await {
|
||||
Ok(Some(prop)) => {
|
||||
let action_hint = PropStateView::detect_action_hint(&prop.server_state);
|
||||
// Get owner display name if prop was dropped by someone
|
||||
let owner_name = if let Some(dropped_by) = prop.dropped_by {
|
||||
users::get_user_by_id(&pool, dropped_by).await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.display_name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(PropStateView {
|
||||
prop_id,
|
||||
prop_name: prop.prop_name,
|
||||
owner_display_name: owner_name,
|
||||
server_state: prop.server_state,
|
||||
// Realm state visible if user is in same realm
|
||||
realm_state: Some(prop.realm_state),
|
||||
user_state: prop.user_state,
|
||||
// No private state for loose props
|
||||
private_state: None,
|
||||
action_hint,
|
||||
})
|
||||
}
|
||||
Ok(None) => Err(AppError::NotFound("Loose prop not found".to_string())),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
} else {
|
||||
// Get inventory item state
|
||||
match inventory::get_inventory_item(&pool, prop_id, user_id).await {
|
||||
Ok(Some(item)) => {
|
||||
let action_hint = PropStateView::detect_action_hint(&item.server_state);
|
||||
Ok(PropStateView {
|
||||
prop_id,
|
||||
prop_name: item.prop_name,
|
||||
owner_display_name: None, // It's the user's own item
|
||||
server_state: item.server_state,
|
||||
realm_state: Some(item.realm_state),
|
||||
user_state: item.user_state,
|
||||
// Include private state since viewer is owner
|
||||
private_state: Some(PrivateStateBundle {
|
||||
server_private_state: item.server_private_state,
|
||||
realm_private_state: item.realm_private_state,
|
||||
user_private_state: item.user_private_state,
|
||||
}),
|
||||
action_hint,
|
||||
})
|
||||
}
|
||||
Ok(None) => Err(AppError::NotFound("Inventory item not found".to_string())),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(view) => {
|
||||
let _ = direct_tx.send(ServerMessage::PropStateViewResponse { view }).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] View prop state failed: {:?}", e);
|
||||
let _ = direct_tx.send(ServerMessage::Error {
|
||||
code: "VIEW_STATE_FAILED".to_string(),
|
||||
message: format!("{:?}", e),
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(close_frame) => {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ use leptos::prelude::*;
|
|||
use leptos::reactive::owner::LocalStorage;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::{InventoryItem, PropAcquisitionInfo};
|
||||
use chattyness_db::models::{InventoryItem, PropAcquisitionInfo, StateScope, StateVisibility};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
use leptos::ev::Event;
|
||||
|
||||
use super::modals::{ConfirmModal, GuestLockedOverlay, Modal};
|
||||
use super::tabs::{Tab, TabBar};
|
||||
|
|
@ -372,6 +373,7 @@ pub fn InventoryPopup(
|
|||
set_delete_confirm_item.set(Some((id, name)));
|
||||
})
|
||||
on_delete_immediate=Callback::new(handle_delete)
|
||||
ws_sender=ws_sender
|
||||
/>
|
||||
</Show>
|
||||
|
||||
|
|
@ -457,6 +459,8 @@ fn MyInventoryTab(
|
|||
#[prop(into)] on_delete_request: Callback<(Uuid, String)>,
|
||||
/// Callback for immediate delete (Shift+Delete, no confirmation)
|
||||
#[prop(into)] on_delete_immediate: Callback<Uuid>,
|
||||
/// WebSocket sender for updating item state
|
||||
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
|
||||
) -> impl IntoView {
|
||||
// NodeRef to maintain focus on container after item removal
|
||||
let container_ref = NodeRef::<leptos::html::Div>::new();
|
||||
|
|
@ -678,6 +682,21 @@ fn MyInventoryTab(
|
|||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Show BusinessCardEditor for business card props
|
||||
{
|
||||
let is_business_card = item.prop_name.to_lowercase().contains("businesscard");
|
||||
if is_business_card {
|
||||
Some(view! {
|
||||
<BusinessCardEditor
|
||||
item=item.clone()
|
||||
ws_sender=ws_sender
|
||||
/>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
|
|
@ -687,6 +706,164 @@ fn MyInventoryTab(
|
|||
}
|
||||
}
|
||||
|
||||
/// Helper function to get string value from Event target.
|
||||
fn event_target_value(ev: &Event) -> String {
|
||||
use leptos::wasm_bindgen::JsCast;
|
||||
ev.target()
|
||||
.and_then(|t| t.dyn_into::<leptos::web_sys::HtmlInputElement>().ok())
|
||||
.map(|input| input.value())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Business card editor component for customizing business card props.
|
||||
///
|
||||
/// Allows editing profile information (name, title, social links) that will
|
||||
/// be displayed when other users view the prop.
|
||||
#[component]
|
||||
fn BusinessCardEditor(
|
||||
item: InventoryItem,
|
||||
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
|
||||
) -> impl IntoView {
|
||||
// Extract existing profile data from server_state
|
||||
let profile = item
|
||||
.server_state
|
||||
.get("profile")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let (name, set_name) = signal(
|
||||
profile
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (title, set_title) = signal(
|
||||
profile
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (linkedin, set_linkedin) = signal(
|
||||
profile
|
||||
.get("linkedin")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (github, set_github) = signal(
|
||||
profile
|
||||
.get("github")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (website, set_website) = signal(
|
||||
profile
|
||||
.get("website")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (saving, set_saving) = signal(false);
|
||||
let (save_message, set_save_message) = signal(Option::<(bool, String)>::None);
|
||||
|
||||
let item_id = item.id;
|
||||
|
||||
let handle_save = move |_| {
|
||||
set_saving.set(true);
|
||||
set_save_message.set(None);
|
||||
|
||||
let state = serde_json::json!({
|
||||
"profile": {
|
||||
"name": name.get(),
|
||||
"title": title.get(),
|
||||
"linkedin": linkedin.get(),
|
||||
"github": github.get(),
|
||||
"website": website.get()
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::UpdateItemState {
|
||||
inventory_item_id: item_id,
|
||||
scope: StateScope::Server,
|
||||
visibility: StateVisibility::Public,
|
||||
state,
|
||||
merge: false, // Replace entire server_state
|
||||
});
|
||||
set_save_message.set(Some((true, "Saved!".to_string())));
|
||||
} else {
|
||||
set_save_message.set(Some((false, "Not connected".to_string())));
|
||||
}
|
||||
});
|
||||
|
||||
set_saving.set(false);
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
||||
<h4 class="text-white font-medium mb-3">"Business Card Details"</h4>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your Name"
|
||||
prop:value=move || name.get()
|
||||
on:input=move |e| set_name.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title / Position"
|
||||
prop:value=move || title.get()
|
||||
on:input=move |e| set_title.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="LinkedIn username"
|
||||
prop:value=move || linkedin.get()
|
||||
on:input=move |e| set_linkedin.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="GitHub username"
|
||||
prop:value=move || github.get()
|
||||
on:input=move |e| set_github.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Website URL"
|
||||
prop:value=move || website.get()
|
||||
on:input=move |e| set_website.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50"
|
||||
on:click=handle_save
|
||||
disabled=move || saving.get()
|
||||
>
|
||||
{move || if saving.get() { "Saving..." } else { "Save Business Card" }}
|
||||
</button>
|
||||
{move || save_message.get().map(|(success, msg)| {
|
||||
let class = if success {
|
||||
"text-green-400 text-sm mt-2"
|
||||
} else {
|
||||
"text-red-400 text-sm mt-2"
|
||||
};
|
||||
view! { <p class=class>{msg}</p> }
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquisition props tab content with acquire functionality.
|
||||
#[component]
|
||||
fn AcquisitionPropsTab(
|
||||
|
|
|
|||
|
|
@ -356,3 +356,251 @@ pub fn GuestLockedOverlay() -> impl IntoView {
|
|||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prop State View Modal
|
||||
// ============================================================================
|
||||
|
||||
use chattyness_db::models::{PropActionHint, PropStateView};
|
||||
|
||||
/// Modal for viewing prop state (business cards, info panels, etc.).
|
||||
///
|
||||
/// Automatically renders appropriate view based on action_hint:
|
||||
/// - BusinessCard: Shows profile with social links
|
||||
/// - ExternalLinks: Shows list of clickable links
|
||||
/// - InfoPanel: Shows formatted JSON state
|
||||
#[component]
|
||||
pub fn PropInfoModal(
|
||||
#[prop(into)] open: Signal<bool>,
|
||||
#[prop(into)] prop_state: Signal<Option<PropStateView>>,
|
||||
on_close: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
use crate::utils::use_escape_key;
|
||||
|
||||
// Handle escape key
|
||||
use_escape_key(open, on_close.clone());
|
||||
|
||||
let on_close_backdrop = on_close.clone();
|
||||
let on_close_button = on_close.clone();
|
||||
|
||||
view! {
|
||||
<Show when=move || open.get() && prop_state.get().is_some()>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="prop-info-modal-title"
|
||||
>
|
||||
// Backdrop
|
||||
<div
|
||||
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
on:click=move |_| on_close_backdrop.run(())
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
// Modal content
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
|
||||
// Close button
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
on:click=move |_| on_close_button.run(())
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{move || {
|
||||
let Some(state) = prop_state.get() else { return view! { <div/> }.into_any() };
|
||||
|
||||
view! {
|
||||
<div>
|
||||
// Header
|
||||
<h2 id="prop-info-modal-title" class="text-xl font-bold text-white mb-2">
|
||||
{state.prop_name.clone()}
|
||||
</h2>
|
||||
{state.owner_display_name.clone().map(|name| view! {
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
"From: " <span class="text-blue-400">{name}</span>
|
||||
</p>
|
||||
})}
|
||||
|
||||
// Content based on action hint
|
||||
{match state.action_hint {
|
||||
Some(PropActionHint::BusinessCard) => {
|
||||
view! {
|
||||
<BusinessCardView server_state=state.server_state />
|
||||
}.into_any()
|
||||
}
|
||||
Some(PropActionHint::ExternalLinks) => {
|
||||
view! {
|
||||
<ExternalLinksView server_state=state.server_state />
|
||||
}.into_any()
|
||||
}
|
||||
Some(PropActionHint::InfoPanel) | None => {
|
||||
view! {
|
||||
<InfoPanelView server_state=state.server_state />
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
/// Business card view for props with profile information.
|
||||
///
|
||||
/// Displays profile fields and social media link buttons.
|
||||
#[component]
|
||||
pub fn BusinessCardView(
|
||||
server_state: serde_json::Value,
|
||||
) -> impl IntoView {
|
||||
// Extract profile from server_state
|
||||
let profile = server_state.get("profile").cloned().unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let name = profile.get("name").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let title = profile.get("title").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let company = profile.get("company").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let linkedin = profile.get("linkedin").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let github = profile.get("github").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let twitter = profile.get("twitter").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let website = profile.get("website").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
|
||||
view! {
|
||||
<div class="space-y-4">
|
||||
// Profile info
|
||||
<div class="text-center">
|
||||
{name.clone().map(|n| view! {
|
||||
<h3 class="text-2xl font-bold text-white">{n}</h3>
|
||||
})}
|
||||
{title.clone().map(|t| view! {
|
||||
<p class="text-gray-300">{t}</p>
|
||||
})}
|
||||
{company.clone().map(|c| view! {
|
||||
<p class="text-gray-400 text-sm">{c}</p>
|
||||
})}
|
||||
</div>
|
||||
|
||||
// Social links
|
||||
<div class="flex flex-wrap justify-center gap-2 pt-4 border-t border-gray-700">
|
||||
{linkedin.clone().map(|username| {
|
||||
let url = format!("https://linkedin.com/in/{}", username);
|
||||
view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-700 hover:bg-blue-600 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
"LinkedIn"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
|
||||
{github.clone().map(|username| {
|
||||
let url = format!("https://github.com/{}", username);
|
||||
view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
"GitHub"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
|
||||
{twitter.clone().map(|username| {
|
||||
let url = format!("https://twitter.com/{}", username);
|
||||
view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-sky-600 hover:bg-sky-500 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"/>
|
||||
</svg>
|
||||
"Twitter"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
|
||||
{website.clone().map(|url| {
|
||||
view! {
|
||||
<a
|
||||
href=url.clone()
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||
</svg>
|
||||
"Website"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// External links view for props with link arrays.
|
||||
#[component]
|
||||
pub fn ExternalLinksView(
|
||||
server_state: serde_json::Value,
|
||||
) -> impl IntoView {
|
||||
let links = server_state
|
||||
.get("links")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
view! {
|
||||
<div class="space-y-2">
|
||||
{links.into_iter().filter_map(|link| {
|
||||
let label = link.get("label").and_then(|v| v.as_str()).map(|s| s.to_string())?;
|
||||
let url = link.get("url").and_then(|v| v.as_str()).map(|s| s.to_string())?;
|
||||
Some(view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block w-full px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-center transition-colors"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
})
|
||||
}).collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Info panel view for props with arbitrary state.
|
||||
#[component]
|
||||
pub fn InfoPanelView(
|
||||
server_state: serde_json::Value,
|
||||
) -> impl IntoView {
|
||||
let formatted = serde_json::to_string_pretty(&server_state).unwrap_or_default();
|
||||
|
||||
view! {
|
||||
<div class="bg-gray-900 rounded-lg p-4 overflow-auto max-h-64">
|
||||
<pre class="text-gray-300 text-sm font-mono whitespace-pre-wrap">{formatted}</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ pub fn RealmSceneViewer(
|
|||
#[prop(optional, into)] on_prop_move: Option<Callback<(Uuid, f64, f64)>>,
|
||||
#[prop(optional, into)] on_prop_lock_toggle: Option<Callback<(Uuid, bool)>>,
|
||||
#[prop(optional, into)] on_prop_delete: Option<Callback<Uuid>>,
|
||||
#[prop(optional, into)] on_view_prop_state: Option<Callback<Uuid>>,
|
||||
) -> impl IntoView {
|
||||
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
||||
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
|
||||
|
|
@ -608,6 +609,8 @@ pub fn RealmSceneViewer(
|
|||
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
|
||||
let is_locked = prop_context_is_locked.get();
|
||||
let mut items = Vec::new();
|
||||
// Always show View Info for props (to view state/business cards)
|
||||
items.push(ContextMenuItem { label: "View Info".to_string(), action: "view_info".to_string() });
|
||||
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() });
|
||||
|
|
@ -626,8 +629,16 @@ pub fn RealmSceneViewer(
|
|||
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();
|
||||
let on_view_prop_state = on_view_prop_state.clone();
|
||||
move |action: String| {
|
||||
match action.as_str() {
|
||||
"view_info" => {
|
||||
if let Some(prop_id) = prop_context_menu_target.get() {
|
||||
if let Some(ref callback) = on_view_prop_state {
|
||||
callback.run(prop_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
"pick_up" => {
|
||||
if let Some(prop_id) = prop_context_menu_target.get() {
|
||||
on_prop_click.run(prop_id);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use leptos::reactive::owner::LocalStorage;
|
|||
|
||||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::models::EmotionState;
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, PropStateView};
|
||||
use chattyness_db::ws_messages::{close_codes, ClientMessage};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage};
|
||||
|
|
@ -155,6 +155,8 @@ pub enum WsEvent {
|
|||
ModCommandResult(ModCommandResultInfo),
|
||||
/// Member identity updated (e.g., guest → user).
|
||||
MemberIdentityUpdated(MemberIdentityInfo),
|
||||
/// Prop state view response received.
|
||||
PropStateView(PropStateView),
|
||||
}
|
||||
|
||||
/// Consolidated internal state to reduce Rc<RefCell<>> proliferation.
|
||||
|
|
@ -528,6 +530,7 @@ fn handle_server_message(
|
|||
TeleportApproved(TeleportInfo),
|
||||
Summoned(SummonInfo),
|
||||
ModCommandResult(ModCommandResultInfo),
|
||||
PropStateView(PropStateView),
|
||||
}
|
||||
|
||||
let action = {
|
||||
|
|
@ -761,6 +764,13 @@ fn handle_server_message(
|
|||
}
|
||||
PostAction::UpdateMembers(state.members.clone())
|
||||
}
|
||||
ServerMessage::PropStateViewResponse { view } => {
|
||||
PostAction::PropStateView(view)
|
||||
}
|
||||
ServerMessage::ItemStateUpdated { .. } => {
|
||||
// State update confirmed - could refresh inventory if needed
|
||||
PostAction::None
|
||||
}
|
||||
}
|
||||
}; // state borrow is dropped here
|
||||
|
||||
|
|
@ -805,6 +815,9 @@ fn handle_server_message(
|
|||
PostAction::ModCommandResult(info) => {
|
||||
on_event.run(WsEvent::ModCommandResult(info));
|
||||
}
|
||||
PostAction::PropStateView(view) => {
|
||||
on_event.run(WsEvent::PropStateView(view));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use uuid::Uuid;
|
|||
use crate::components::{
|
||||
ActiveBubble, AvatarEditorPopup, AvatarStorePopup, Card, ChatInput, ConversationModal,
|
||||
EmotionKeybindings, FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup,
|
||||
MessageLog, NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer,
|
||||
MessageLog, NotificationMessage, NotificationToast, PropInfoModal, RealmHeader, RealmSceneViewer,
|
||||
ReconnectionOverlay, RegisterModal, SettingsPopup, ViewerSettings,
|
||||
};
|
||||
#[cfg(feature = "hydrate")]
|
||||
|
|
@ -26,8 +26,8 @@ use crate::utils::LocalStoragePersist;
|
|||
#[cfg(feature = "hydrate")]
|
||||
use crate::utils::parse_bounds_dimensions;
|
||||
use chattyness_db::models::{
|
||||
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole,
|
||||
RealmWithUserRole, Scene, SceneSummary,
|
||||
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, PropStateView,
|
||||
RealmRole, RealmWithUserRole, Scene, SceneSummary,
|
||||
};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::ws_messages::{close_codes, ClientMessage};
|
||||
|
|
@ -152,6 +152,10 @@ pub fn RealmPage() -> impl IntoView {
|
|||
// Mod notification state (for summon notifications, command results)
|
||||
let (mod_notification, set_mod_notification) = signal(Option::<(bool, String)>::None);
|
||||
|
||||
// Prop info modal state (for viewing prop state/business cards)
|
||||
let (prop_info_modal_open, set_prop_info_modal_open) = signal(false);
|
||||
let (prop_info_state, set_prop_info_state) = signal(Option::<PropStateView>::None);
|
||||
|
||||
let realm_data = LocalResource::new(move || {
|
||||
let slug = slug.get();
|
||||
async move {
|
||||
|
|
@ -484,6 +488,10 @@ pub fn RealmPage() -> impl IntoView {
|
|||
}
|
||||
});
|
||||
}
|
||||
WsEvent::PropStateView(view) => {
|
||||
set_prop_info_state.set(Some(view));
|
||||
set_prop_info_modal_open.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -711,6 +719,22 @@ pub fn RealmPage() -> impl IntoView {
|
|||
#[cfg(not(feature = "hydrate"))]
|
||||
let on_prop_click = Callback::new(move |_prop_id: Uuid| {});
|
||||
|
||||
// Handle prop state view request (View Info) via WebSocket
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_view_prop_state = Callback::new(move |prop_id: Uuid| {
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::ViewPropState {
|
||||
prop_id,
|
||||
is_loose_prop: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let on_view_prop_state = Callback::new(move |_prop_id: Uuid| {});
|
||||
|
||||
// Handle global keyboard shortcuts (e+0-9 for emotions, : for chat focus)
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
|
|
@ -1254,6 +1278,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
}
|
||||
});
|
||||
});
|
||||
let on_view_prop_state_cb = on_view_prop_state.clone();
|
||||
view! {
|
||||
<div class="relative w-full">
|
||||
<RealmSceneViewer
|
||||
|
|
@ -1280,6 +1305,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
on_prop_move=on_prop_move_cb
|
||||
on_prop_lock_toggle=on_prop_lock_toggle_cb
|
||||
on_prop_delete=on_prop_delete_cb
|
||||
on_view_prop_state=on_view_prop_state_cb
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||||
<ChatInput
|
||||
|
|
@ -1570,6 +1596,16 @@ pub fn RealmPage() -> impl IntoView {
|
|||
}
|
||||
}
|
||||
|
||||
// Prop info modal (for viewing business cards, etc.)
|
||||
<PropInfoModal
|
||||
open=Signal::derive(move || prop_info_modal_open.get())
|
||||
prop_state=Signal::derive(move || prop_info_state.get())
|
||||
on_close=Callback::new(move |_| {
|
||||
set_prop_info_modal_open.set(false);
|
||||
set_prop_info_state.set(None);
|
||||
})
|
||||
/>
|
||||
|
||||
// Reconnection overlay - shown when WebSocket disconnects
|
||||
{
|
||||
#[cfg(feature = "hydrate")]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue