diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 5297326..7b3eb62 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -640,6 +640,23 @@ pub struct InventoryResponse { pub items: Vec, } +/// A public prop from server or realm library. +/// Used for the public inventory tabs (Server/Realm). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct PublicProp { + pub id: Uuid, + pub name: String, + pub asset_path: String, + pub description: Option, +} + +/// Response for public props list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicPropsResponse { + pub props: Vec, +} + /// A prop dropped in a channel, available for pickup. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] @@ -680,6 +697,7 @@ pub struct ServerProp { pub is_transferable: bool, pub is_portable: bool, pub is_droppable: bool, + pub is_public: bool, pub is_active: bool, pub available_from: Option>, pub available_until: Option>, @@ -720,6 +738,12 @@ pub struct CreateServerPropRequest { /// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8 #[serde(default)] pub default_position: Option, + /// Whether prop is droppable (can be dropped in a channel). + #[serde(default)] + pub droppable: Option, + /// Whether prop appears in the public Server inventory tab. + #[serde(default)] + pub public: Option, } #[cfg(feature = "ssr")] diff --git a/crates/chattyness-db/src/queries/inventory.rs b/crates/chattyness-db/src/queries/inventory.rs index 4ae1e22..6047292 100644 --- a/crates/chattyness-db/src/queries/inventory.rs +++ b/crates/chattyness-db/src/queries/inventory.rs @@ -3,7 +3,7 @@ use sqlx::PgExecutor; use uuid::Uuid; -use crate::models::InventoryItem; +use crate::models::{InventoryItem, PublicProp}; use chattyness_error::AppError; /// List all inventory items for a user. @@ -91,3 +91,67 @@ pub async fn drop_inventory_item<'e>( Ok(()) } + +/// List all public server props. +/// +/// Returns props that are: +/// - Active (`is_active = true`) +/// - Public (`is_public = true`) +/// - Currently available (within availability window if set) +pub async fn list_public_server_props<'e>( + executor: impl PgExecutor<'e>, +) -> Result, AppError> { + let props = sqlx::query_as::<_, PublicProp>( + r#" + SELECT + id, + name, + asset_path, + description + FROM server.props + WHERE is_active = true + AND is_public = true + AND (available_from IS NULL OR available_from <= now()) + AND (available_until IS NULL OR available_until > now()) + ORDER BY name ASC + "#, + ) + .fetch_all(executor) + .await?; + + Ok(props) +} + +/// List all public realm props for a specific realm. +/// +/// Returns props that are: +/// - In the specified realm +/// - Active (`is_active = true`) +/// - Public (`is_public = true`) +/// - Currently available (within availability window if set) +pub async fn list_public_realm_props<'e>( + executor: impl PgExecutor<'e>, + realm_id: Uuid, +) -> Result, AppError> { + let props = sqlx::query_as::<_, PublicProp>( + r#" + SELECT + id, + name, + asset_path, + description + FROM realm.props + WHERE realm_id = $1 + AND is_active = true + AND is_public = true + AND (available_from IS NULL OR available_from <= now()) + AND (available_until IS NULL OR available_until > now()) + ORDER BY name ASC + "#, + ) + .bind(realm_id) + .fetch_all(executor) + .await?; + + Ok(props) +} diff --git a/crates/chattyness-db/src/queries/props.rs b/crates/chattyness-db/src/queries/props.rs index 1eeca26..82dce8c 100644 --- a/crates/chattyness-db/src/queries/props.rs +++ b/crates/chattyness-db/src/queries/props.rs @@ -52,6 +52,7 @@ pub async fn get_server_prop_by_id<'e>( is_transferable, is_portable, is_droppable, + is_public, is_active, available_from, available_until, @@ -114,17 +115,22 @@ pub async fn create_server_prop<'e>( (None, None, None) }; + let is_droppable = req.droppable.unwrap_or(true); + let is_public = req.public.unwrap_or(false); + let prop = sqlx::query_as::<_, ServerProp>( r#" INSERT INTO server.props ( name, slug, description, tags, asset_path, default_layer, default_emotion, default_position, + is_droppable, is_public, created_by ) VALUES ( $1, $2, $3, $4, $5, $6::server.avatar_layer, $7::server.emotion_state, $8, - $9 + $9, $10, + $11 ) RETURNING id, @@ -141,6 +147,7 @@ pub async fn create_server_prop<'e>( is_transferable, is_portable, is_droppable, + is_public, is_active, available_from, available_until, @@ -157,6 +164,8 @@ pub async fn create_server_prop<'e>( .bind(&default_layer) .bind(&default_emotion) .bind(default_position) + .bind(is_droppable) + .bind(is_public) .bind(created_by) .fetch_one(executor) .await?; @@ -198,17 +207,22 @@ pub async fn upsert_server_prop<'e>( (None, None, None) }; + let is_droppable = req.droppable.unwrap_or(true); + let is_public = req.public.unwrap_or(false); + let prop = sqlx::query_as::<_, ServerProp>( r#" INSERT INTO server.props ( name, slug, description, tags, asset_path, default_layer, default_emotion, default_position, + is_droppable, is_public, created_by ) VALUES ( $1, $2, $3, $4, $5, $6::server.avatar_layer, $7::server.emotion_state, $8, - $9 + $9, $10, + $11 ) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, @@ -218,6 +232,8 @@ pub async fn upsert_server_prop<'e>( default_layer = EXCLUDED.default_layer, default_emotion = EXCLUDED.default_emotion, default_position = EXCLUDED.default_position, + is_droppable = EXCLUDED.is_droppable, + is_public = EXCLUDED.is_public, updated_at = now() RETURNING id, @@ -234,6 +250,7 @@ pub async fn upsert_server_prop<'e>( is_transferable, is_portable, is_droppable, + is_public, is_active, available_from, available_until, @@ -250,6 +267,8 @@ pub async fn upsert_server_prop<'e>( .bind(&default_layer) .bind(&default_emotion) .bind(default_position) + .bind(is_droppable) + .bind(is_public) .bind(created_by) .fetch_one(executor) .await?; diff --git a/crates/chattyness-user-ui/src/api/inventory.rs b/crates/chattyness-user-ui/src/api/inventory.rs index 69dd766..dc82ce0 100644 --- a/crates/chattyness-user-ui/src/api/inventory.rs +++ b/crates/chattyness-user-ui/src/api/inventory.rs @@ -2,11 +2,15 @@ //! //! Handles inventory listing and item management. -use axum::extract::Path; +use axum::extract::{Path, State}; use axum::Json; +use sqlx::PgPool; use uuid::Uuid; -use chattyness_db::{models::InventoryResponse, queries::inventory}; +use chattyness_db::{ + models::{InventoryResponse, PublicPropsResponse}, + queries::{inventory, realms}, +}; use chattyness_error::AppError; use crate::auth::{AuthUser, RlsConn}; @@ -39,3 +43,31 @@ pub async fn drop_item( Ok(Json(serde_json::json!({ "success": true }))) } + +/// Get public server props. +/// +/// GET /api/inventory/server +pub async fn get_server_props( + State(pool): State, +) -> Result, AppError> { + let props = inventory::list_public_server_props(&pool).await?; + + Ok(Json(PublicPropsResponse { props })) +} + +/// Get public realm props. +/// +/// GET /api/realms/{slug}/inventory +pub async fn get_realm_props( + State(pool): State, + Path(slug): Path, +) -> Result, AppError> { + // Get the realm by slug to get its ID + let realm = realms::get_realm_by_slug(&pool, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + let props = inventory::list_public_realm_props(&pool, realm.id).await?; + + Ok(Json(PublicPropsResponse { props })) +} diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index c32b223..ffc5240 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -57,4 +57,10 @@ pub fn api_router() -> Router { "/inventory/{item_id}", axum::routing::delete(inventory::drop_item), ) + // Public inventory routes (public server/realm props) + .route("/inventory/server", get(inventory::get_server_props)) + .route( + "/realms/{slug}/inventory", + get(inventory::get_realm_props), + ) } diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index b06c22a..97b7620 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -4,7 +4,7 @@ use leptos::prelude::*; use leptos::reactive::owner::LocalStorage; use uuid::Uuid; -use chattyness_db::models::InventoryItem; +use chattyness_db::models::{InventoryItem, PublicProp}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; @@ -12,25 +12,49 @@ use super::ws_client::WsSender; /// Inventory popup component. /// -/// Shows a grid of user-owned props with drop functionality. +/// Shows a tabbed interface with: +/// - My Inventory: User-owned props with drop functionality +/// - Server: Public server-wide props +/// - Realm: Public realm-specific props /// /// Props: /// - `open`: Signal controlling visibility /// - `on_close`: Callback when popup should close /// - `ws_sender`: WebSocket sender for dropping props +/// - `realm_slug`: Current realm slug for fetching realm props #[component] pub fn InventoryPopup( #[prop(into)] open: Signal, on_close: Callback<()>, ws_sender: StoredValue, LocalStorage>, + #[prop(into)] realm_slug: Signal, ) -> impl IntoView { + // Tab state + let (active_tab, set_active_tab) = signal("my_inventory"); + + // My Inventory state let (items, set_items) = signal(Vec::::new()); let (loading, set_loading) = signal(false); let (error, set_error) = signal(Option::::None); let (selected_item, set_selected_item) = signal(Option::::None); let (dropping, set_dropping) = signal(false); - // Fetch inventory when popup opens + // Server props state + let (server_props, set_server_props) = signal(Vec::::new()); + let (server_loading, set_server_loading) = signal(false); + let (server_error, set_server_error) = signal(Option::::None); + + // Realm props state + let (realm_props, set_realm_props) = signal(Vec::::new()); + let (realm_loading, set_realm_loading) = signal(false); + let (realm_error, set_realm_error) = signal(Option::::None); + + // Track if tabs have been loaded (to avoid re-fetching) + let (my_inventory_loaded, set_my_inventory_loaded) = signal(false); + let (server_loaded, set_server_loaded) = signal(false); + let (realm_loaded, set_realm_loaded) = signal(false); + + // Fetch my inventory when popup opens or tab is selected #[cfg(feature = "hydrate")] { use gloo_net::http::Request; @@ -40,6 +64,14 @@ pub fn InventoryPopup( if !open.get() { // Reset state when closing set_selected_item.set(None); + set_my_inventory_loaded.set(false); + set_server_loaded.set(false); + set_realm_loaded.set(false); + return; + } + + // Only fetch if on my_inventory tab and not already loaded + if active_tab.get() != "my_inventory" || my_inventory_loaded.get() { return; } @@ -54,6 +86,7 @@ pub fn InventoryPopup( resp.json::().await { set_items.set(data.items); + set_my_inventory_loaded.set(true); } else { set_error.set(Some("Failed to parse inventory data".to_string())); } @@ -70,6 +103,94 @@ pub fn InventoryPopup( }); } + // Fetch server props when server tab is selected + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + use leptos::task::spawn_local; + + Effect::new(move |_| { + if !open.get() || active_tab.get() != "server" || server_loaded.get() { + return; + } + + set_server_loading.set(true); + set_server_error.set(None); + + spawn_local(async move { + let response = Request::get("/api/inventory/server").send().await; + match response { + Ok(resp) if resp.ok() => { + if let Ok(data) = + resp.json::().await + { + set_server_props.set(data.props); + set_server_loaded.set(true); + } else { + set_server_error.set(Some("Failed to parse server props".to_string())); + } + } + Ok(resp) => { + set_server_error + .set(Some(format!("Failed to load server props: {}", resp.status()))); + } + Err(e) => { + set_server_error.set(Some(format!("Network error: {}", e))); + } + } + set_server_loading.set(false); + }); + }); + } + + // Fetch realm props when realm tab is selected + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + use leptos::task::spawn_local; + + Effect::new(move |_| { + if !open.get() || active_tab.get() != "realm" || realm_loaded.get() { + return; + } + + let slug = realm_slug.get(); + if slug.is_empty() { + set_realm_error.set(Some("No realm selected".to_string())); + return; + } + + set_realm_loading.set(true); + set_realm_error.set(None); + + spawn_local(async move { + let response = Request::get(&format!("/api/realms/{}/inventory", slug)) + .send() + .await; + match response { + Ok(resp) if resp.ok() => { + if let Ok(data) = + resp.json::().await + { + set_realm_props.set(data.props); + set_realm_loaded.set(true); + } else { + set_realm_error.set(Some("Failed to parse realm props".to_string())); + } + } + Ok(resp) => { + set_realm_error + .set(Some(format!("Failed to load realm props: {}", resp.status()))); + } + Err(e) => { + set_realm_error.set(Some(format!("Network error: {}", e))); + } + } + set_realm_loading.set(false); + }); + }); + } + // Handle escape key to close #[cfg(feature = "hydrate")] { @@ -166,141 +287,359 @@ pub fn InventoryPopup( - // Loading state - -
-

"Loading inventory..."

-
-
+ // Tab bar +
+ + + +
- // Error state - -
-

{move || error.get().unwrap_or_default()}

-
-
+ // Tab content +
+ // My Inventory tab + + + - // Empty state - -
-
- -
-

"Your inventory is empty"

-

"Collect props to see them here"

-
-
+ // Server tab + + + - // Grid of items - -
-
- - - - } - } - /> -
-
-
- - // Selected item details and actions - {move || { - let item_id = selected_item.get()?; - let item = items.get().into_iter().find(|i| i.id == item_id)?; - let handle_drop = handle_drop.clone(); - let is_dropping = dropping.get(); - let is_droppable = item.is_droppable; - - Some(view! { -
-
-
-

{item.prop_name.clone()}

-

- {if item.is_transferable { "Transferable" } else { "Not transferable" }} - {if item.is_portable { " \u{2022} Portable" } else { "" }} - {if is_droppable { " \u{2022} Droppable" } else { " \u{2022} Essential" }} -

-
-
- // Drop button - disabled for non-droppable (essential) props - - // Transfer button (disabled for now) - - - -
-
-
- }) - }} + // Realm tab + + + +
} } + +/// My Inventory tab content with drop functionality. +#[component] +fn MyInventoryTab( + #[prop(into)] items: Signal>, + #[prop(into)] loading: Signal, + #[prop(into)] error: Signal>, + #[prop(into)] selected_item: Signal>, + set_selected_item: WriteSignal>, + #[prop(into)] dropping: Signal, + #[prop(into)] on_drop: Callback, +) -> impl IntoView { + view! { + // Loading state + +
+

"Loading inventory..."

+
+
+ + // Error state + +
+

{move || error.get().unwrap_or_default()}

+
+
+ + // Empty state + +
+
+ +
+

"Your inventory is empty"

+

"Collect props to see them here"

+
+
+ + // Grid of items + +
+
+ + + + } + } + /> +
+ + // Selected item details and actions + {move || { + let item_id = selected_item.get()?; + let item = items.get().into_iter().find(|i| i.id == item_id)?; + let on_drop = on_drop.clone(); + let is_dropping = dropping.get(); + let is_droppable = item.is_droppable; + + Some(view! { +
+
+
+

{item.prop_name.clone()}

+

+ {if item.is_transferable { "Transferable" } else { "Not transferable" }} + {if item.is_portable { " \u{2022} Portable" } else { "" }} + {if is_droppable { " \u{2022} Droppable" } else { " \u{2022} Essential" }} +

+
+
+ // Drop button - disabled for non-droppable (essential) props + + // Transfer button (disabled for now) + + + +
+
+
+ }) + }} +
+
+ } +} + +/// Public props tab content (read-only display). +#[component] +fn PublicPropsTab( + #[prop(into)] props: Signal>, + #[prop(into)] loading: Signal, + #[prop(into)] error: Signal>, + tab_name: &'static str, + empty_message: &'static str, +) -> impl IntoView { + // Selected prop for showing details + let (selected_prop, set_selected_prop) = signal(Option::::None); + + view! { + // Loading state + +
+

{format!("Loading {} props...", tab_name.to_lowercase())}

+
+
+ + // Error state + +
+

{move || error.get().unwrap_or_default()}

+
+
+ + // Empty state + +
+
+ +
+

{empty_message}

+

"Public props will appear here when available"

+
+
+ + // Grid of props + +
+
+ + + + } + } + /> +
+ + // Selected prop details (read-only) + {move || { + let prop_id = selected_prop.get()?; + let prop = props.get().into_iter().find(|p| p.id == prop_id)?; + + Some(view! { +
+
+
+

{prop.name.clone()}

+ {prop.description.map(|desc| view! { +

{desc}

+ })} +
+

"View only"

+
+
+ }) + }} +
+
+ } +} diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 095028c..fc6992c 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -709,6 +709,7 @@ pub fn RealmPage() -> impl IntoView { set_inventory_open.set(false); }) ws_sender=ws_sender_for_inv + realm_slug=Signal::derive(move || slug.get()) /> } } diff --git a/db/reinitialize_all_users.sql b/db/reinitialize_all_users.sql index 71e8130..c1f7e27 100644 --- a/db/reinitialize_all_users.sql +++ b/db/reinitialize_all_users.sql @@ -9,9 +9,9 @@ BEGIN FOR v_user IN SELECT id, username FROM auth.users LOOP -- Clear existing data - DELETE FROM props.active_avatars WHERE user_id = v_user.id; - DELETE FROM props.avatars WHERE user_id = v_user.id; - DELETE FROM props.inventory WHERE user_id = v_user.id; + DELETE FROM auth.active_avatars WHERE user_id = v_user.id; + DELETE FROM auth.avatars WHERE user_id = v_user.id; + DELETE FROM auth.inventory WHERE user_id = v_user.id; -- Reinitialize with current server props PERFORM auth.initialize_new_user(v_user.id); diff --git a/db/reinitialize_user.md b/db/reinitialize_user.md index cc09b8f..9a7fe17 100644 --- a/db/reinitialize_user.md +++ b/db/reinitialize_user.md @@ -16,9 +16,9 @@ SELECT id, username FROM auth.users WHERE username = 'TARGET_USERNAME'; BEGIN; -- Clear existing props and avatars for the user -DELETE FROM props.active_avatars WHERE user_id = 'USER_UUID'; -DELETE FROM props.avatars WHERE user_id = 'USER_UUID'; -DELETE FROM props.inventory WHERE user_id = 'USER_UUID'; +DELETE FROM auth.active_avatars WHERE user_id = 'USER_UUID'; +DELETE FROM auth.avatars WHERE user_id = 'USER_UUID'; +DELETE FROM auth.inventory WHERE user_id = 'USER_UUID'; -- Reinitialize with current server props SELECT auth.initialize_new_user('USER_UUID'); @@ -29,8 +29,8 @@ COMMIT; 3. Verify the results: ```sql -SELECT COUNT(*) as inventory_count FROM props.inventory WHERE user_id = 'USER_UUID'; -SELECT id, name, slot_number FROM props.avatars WHERE user_id = 'USER_UUID'; +SELECT COUNT(*) as inventory_count FROM auth.inventory WHERE user_id = 'USER_UUID'; +SELECT id, name, slot_number FROM auth.avatars WHERE user_id = 'USER_UUID'; ``` ## Example: Reinitialize ranosh @@ -39,9 +39,9 @@ SELECT id, name, slot_number FROM props.avatars WHERE user_id = 'USER_UUID'; psql -d chattyness <<'EOF' BEGIN; -DELETE FROM props.active_avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4'; -DELETE FROM props.avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4'; -DELETE FROM props.inventory WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4'; +DELETE FROM auth.active_avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4'; +DELETE FROM auth.avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4'; +DELETE FROM auth.inventory WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4'; SELECT auth.initialize_new_user('57a12201-ea0f-4545-9ccc-c4e67ea7e2c4'); diff --git a/db/schema/tables/020_auth.sql b/db/schema/tables/020_auth.sql index 654309b..06d753f 100644 --- a/db/schema/tables/020_auth.sql +++ b/db/schema/tables/020_auth.sql @@ -278,6 +278,7 @@ CREATE TABLE server.props ( is_transferable BOOLEAN NOT NULL DEFAULT true, is_portable BOOLEAN NOT NULL DEFAULT true, is_droppable BOOLEAN NOT NULL DEFAULT true, + is_public BOOLEAN NOT NULL DEFAULT false, is_active BOOLEAN NOT NULL DEFAULT true, available_from TIMESTAMPTZ, @@ -306,6 +307,10 @@ COMMENT ON TABLE server.props IS 'Global prop library (64x64 pixels, center-anch CREATE INDEX idx_server_props_tags ON server.props USING GIN (tags); CREATE INDEX idx_server_props_active ON server.props (is_active) WHERE is_active = true; +CREATE INDEX idx_server_props_public ON server.props (is_public) WHERE is_public = true; + +COMMENT ON COLUMN server.props.is_public IS + 'When true, prop appears in the public Server inventory tab. Uses filtered index idx_server_props_public.'; -- ============================================================================= -- Audio Library diff --git a/db/schema/tables/030_realm.sql b/db/schema/tables/030_realm.sql index f598f13..41dd830 100644 --- a/db/schema/tables/030_realm.sql +++ b/db/schema/tables/030_realm.sql @@ -216,6 +216,7 @@ CREATE TABLE realm.props ( is_unique BOOLEAN NOT NULL DEFAULT false, is_transferable BOOLEAN NOT NULL DEFAULT true, is_droppable BOOLEAN NOT NULL DEFAULT true, + is_public BOOLEAN NOT NULL DEFAULT false, is_active BOOLEAN NOT NULL DEFAULT true, available_from TIMESTAMPTZ, @@ -242,6 +243,10 @@ COMMENT ON TABLE realm.props IS 'Realm-specific prop library'; CREATE INDEX idx_realm_props_realm ON realm.props (realm_id); CREATE INDEX idx_realm_props_tags ON realm.props USING GIN (tags); CREATE INDEX idx_realm_props_active ON realm.props (realm_id, is_active) WHERE is_active = true; +CREATE INDEX idx_realm_props_public ON realm.props (realm_id, is_public) WHERE is_public = true; + +COMMENT ON COLUMN realm.props.is_public IS + 'When true, prop appears in the public Realm inventory tab. Uses filtered index idx_realm_props_public.'; -- ============================================================================= -- Add Foreign Keys to auth tables (now that realm.realms and realm.props exist) diff --git a/stock/props/upload-stockprops.sh b/stock/props/upload-stockprops.sh index 898f77b..9ea5bcb 100755 --- a/stock/props/upload-stockprops.sh +++ b/stock/props/upload-stockprops.sh @@ -111,12 +111,13 @@ for file in "$PROPS_DIR"/*.svg; do echo "Uploading: $filename -> $display_name (category: $category)" - # Create metadata JSON - props are droppable loose items + # Create metadata JSON - props are droppable loose items and public by default metadata=$(cat <