added server and realm tabs to inventory screen

This commit is contained in:
Evan Carroll 2026-01-16 16:47:30 -06:00
parent ee425e224e
commit acab2f017d
12 changed files with 647 additions and 151 deletions

View file

@ -640,6 +640,23 @@ pub struct InventoryResponse {
pub items: Vec<InventoryItem>, pub items: Vec<InventoryItem>,
} }
/// 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<String>,
}
/// Response for public props list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicPropsResponse {
pub props: Vec<PublicProp>,
}
/// A prop dropped in a channel, available for pickup. /// A prop dropped in a channel, available for pickup.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
@ -680,6 +697,7 @@ pub struct ServerProp {
pub is_transferable: bool, pub is_transferable: bool,
pub is_portable: bool, pub is_portable: bool,
pub is_droppable: bool, pub is_droppable: bool,
pub is_public: bool,
pub is_active: bool, pub is_active: bool,
pub available_from: Option<DateTime<Utc>>, pub available_from: Option<DateTime<Utc>>,
pub available_until: Option<DateTime<Utc>>, pub available_until: Option<DateTime<Utc>>,
@ -720,6 +738,12 @@ pub struct CreateServerPropRequest {
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8 /// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
#[serde(default)] #[serde(default)]
pub default_position: Option<i16>, pub default_position: Option<i16>,
/// Whether prop is droppable (can be dropped in a channel).
#[serde(default)]
pub droppable: Option<bool>,
/// Whether prop appears in the public Server inventory tab.
#[serde(default)]
pub public: Option<bool>,
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]

View file

@ -3,7 +3,7 @@
use sqlx::PgExecutor; use sqlx::PgExecutor;
use uuid::Uuid; use uuid::Uuid;
use crate::models::InventoryItem; use crate::models::{InventoryItem, PublicProp};
use chattyness_error::AppError; use chattyness_error::AppError;
/// List all inventory items for a user. /// List all inventory items for a user.
@ -91,3 +91,67 @@ pub async fn drop_inventory_item<'e>(
Ok(()) 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<Vec<PublicProp>, 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<Vec<PublicProp>, 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)
}

View file

@ -52,6 +52,7 @@ pub async fn get_server_prop_by_id<'e>(
is_transferable, is_transferable,
is_portable, is_portable,
is_droppable, is_droppable,
is_public,
is_active, is_active,
available_from, available_from,
available_until, available_until,
@ -114,17 +115,22 @@ pub async fn create_server_prop<'e>(
(None, None, None) (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>( let prop = sqlx::query_as::<_, ServerProp>(
r#" r#"
INSERT INTO server.props ( INSERT INTO server.props (
name, slug, description, tags, asset_path, name, slug, description, tags, asset_path,
default_layer, default_emotion, default_position, default_layer, default_emotion, default_position,
is_droppable, is_public,
created_by created_by
) )
VALUES ( VALUES (
$1, $2, $3, $4, $5, $1, $2, $3, $4, $5,
$6::server.avatar_layer, $7::server.emotion_state, $8, $6::server.avatar_layer, $7::server.emotion_state, $8,
$9 $9, $10,
$11
) )
RETURNING RETURNING
id, id,
@ -141,6 +147,7 @@ pub async fn create_server_prop<'e>(
is_transferable, is_transferable,
is_portable, is_portable,
is_droppable, is_droppable,
is_public,
is_active, is_active,
available_from, available_from,
available_until, available_until,
@ -157,6 +164,8 @@ pub async fn create_server_prop<'e>(
.bind(&default_layer) .bind(&default_layer)
.bind(&default_emotion) .bind(&default_emotion)
.bind(default_position) .bind(default_position)
.bind(is_droppable)
.bind(is_public)
.bind(created_by) .bind(created_by)
.fetch_one(executor) .fetch_one(executor)
.await?; .await?;
@ -198,17 +207,22 @@ pub async fn upsert_server_prop<'e>(
(None, None, None) (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>( let prop = sqlx::query_as::<_, ServerProp>(
r#" r#"
INSERT INTO server.props ( INSERT INTO server.props (
name, slug, description, tags, asset_path, name, slug, description, tags, asset_path,
default_layer, default_emotion, default_position, default_layer, default_emotion, default_position,
is_droppable, is_public,
created_by created_by
) )
VALUES ( VALUES (
$1, $2, $3, $4, $5, $1, $2, $3, $4, $5,
$6::server.avatar_layer, $7::server.emotion_state, $8, $6::server.avatar_layer, $7::server.emotion_state, $8,
$9 $9, $10,
$11
) )
ON CONFLICT (slug) DO UPDATE SET ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name, name = EXCLUDED.name,
@ -218,6 +232,8 @@ pub async fn upsert_server_prop<'e>(
default_layer = EXCLUDED.default_layer, default_layer = EXCLUDED.default_layer,
default_emotion = EXCLUDED.default_emotion, default_emotion = EXCLUDED.default_emotion,
default_position = EXCLUDED.default_position, default_position = EXCLUDED.default_position,
is_droppable = EXCLUDED.is_droppable,
is_public = EXCLUDED.is_public,
updated_at = now() updated_at = now()
RETURNING RETURNING
id, id,
@ -234,6 +250,7 @@ pub async fn upsert_server_prop<'e>(
is_transferable, is_transferable,
is_portable, is_portable,
is_droppable, is_droppable,
is_public,
is_active, is_active,
available_from, available_from,
available_until, available_until,
@ -250,6 +267,8 @@ pub async fn upsert_server_prop<'e>(
.bind(&default_layer) .bind(&default_layer)
.bind(&default_emotion) .bind(&default_emotion)
.bind(default_position) .bind(default_position)
.bind(is_droppable)
.bind(is_public)
.bind(created_by) .bind(created_by)
.fetch_one(executor) .fetch_one(executor)
.await?; .await?;

View file

@ -2,11 +2,15 @@
//! //!
//! Handles inventory listing and item management. //! Handles inventory listing and item management.
use axum::extract::Path; use axum::extract::{Path, State};
use axum::Json; use axum::Json;
use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chattyness_db::{models::InventoryResponse, queries::inventory}; use chattyness_db::{
models::{InventoryResponse, PublicPropsResponse},
queries::{inventory, realms},
};
use chattyness_error::AppError; use chattyness_error::AppError;
use crate::auth::{AuthUser, RlsConn}; use crate::auth::{AuthUser, RlsConn};
@ -39,3 +43,31 @@ pub async fn drop_item(
Ok(Json(serde_json::json!({ "success": true }))) Ok(Json(serde_json::json!({ "success": true })))
} }
/// Get public server props.
///
/// GET /api/inventory/server
pub async fn get_server_props(
State(pool): State<PgPool>,
) -> Result<Json<PublicPropsResponse>, 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<PgPool>,
Path(slug): Path<String>,
) -> Result<Json<PublicPropsResponse>, 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 }))
}

View file

@ -57,4 +57,10 @@ pub fn api_router() -> Router<AppState> {
"/inventory/{item_id}", "/inventory/{item_id}",
axum::routing::delete(inventory::drop_item), 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),
)
} }

View file

@ -4,7 +4,7 @@ use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage; use leptos::reactive::owner::LocalStorage;
use uuid::Uuid; use uuid::Uuid;
use chattyness_db::models::InventoryItem; use chattyness_db::models::{InventoryItem, PublicProp};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
@ -12,25 +12,49 @@ use super::ws_client::WsSender;
/// Inventory popup component. /// 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: /// Props:
/// - `open`: Signal controlling visibility /// - `open`: Signal controlling visibility
/// - `on_close`: Callback when popup should close /// - `on_close`: Callback when popup should close
/// - `ws_sender`: WebSocket sender for dropping props /// - `ws_sender`: WebSocket sender for dropping props
/// - `realm_slug`: Current realm slug for fetching realm props
#[component] #[component]
pub fn InventoryPopup( pub fn InventoryPopup(
#[prop(into)] open: Signal<bool>, #[prop(into)] open: Signal<bool>,
on_close: Callback<()>, on_close: Callback<()>,
ws_sender: StoredValue<Option<WsSender>, LocalStorage>, ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
#[prop(into)] realm_slug: Signal<String>,
) -> impl IntoView { ) -> impl IntoView {
// Tab state
let (active_tab, set_active_tab) = signal("my_inventory");
// My Inventory state
let (items, set_items) = signal(Vec::<InventoryItem>::new()); let (items, set_items) = signal(Vec::<InventoryItem>::new());
let (loading, set_loading) = signal(false); let (loading, set_loading) = signal(false);
let (error, set_error) = signal(Option::<String>::None); let (error, set_error) = signal(Option::<String>::None);
let (selected_item, set_selected_item) = signal(Option::<Uuid>::None); let (selected_item, set_selected_item) = signal(Option::<Uuid>::None);
let (dropping, set_dropping) = signal(false); let (dropping, set_dropping) = signal(false);
// Fetch inventory when popup opens // Server props state
let (server_props, set_server_props) = signal(Vec::<PublicProp>::new());
let (server_loading, set_server_loading) = signal(false);
let (server_error, set_server_error) = signal(Option::<String>::None);
// Realm props state
let (realm_props, set_realm_props) = signal(Vec::<PublicProp>::new());
let (realm_loading, set_realm_loading) = signal(false);
let (realm_error, set_realm_error) = signal(Option::<String>::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")] #[cfg(feature = "hydrate")]
{ {
use gloo_net::http::Request; use gloo_net::http::Request;
@ -40,6 +64,14 @@ pub fn InventoryPopup(
if !open.get() { if !open.get() {
// Reset state when closing // Reset state when closing
set_selected_item.set(None); 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; return;
} }
@ -54,6 +86,7 @@ pub fn InventoryPopup(
resp.json::<chattyness_db::models::InventoryResponse>().await resp.json::<chattyness_db::models::InventoryResponse>().await
{ {
set_items.set(data.items); set_items.set(data.items);
set_my_inventory_loaded.set(true);
} else { } else {
set_error.set(Some("Failed to parse inventory data".to_string())); 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::<chattyness_db::models::PublicPropsResponse>().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::<chattyness_db::models::PublicPropsResponse>().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 // Handle escape key to close
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
@ -166,141 +287,359 @@ pub fn InventoryPopup(
</button> </button>
</div> </div>
// Loading state // Tab bar
<Show when=move || loading.get()> <div class="flex border-b border-gray-700 mb-4" role="tablist">
<div class="flex items-center justify-center py-12"> <button
<p class="text-gray-400">"Loading inventory..."</p> type="button"
</div> role="tab"
</Show> aria-selected=move || active_tab.get() == "my_inventory"
class=move || format!(
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
if active_tab.get() == "my_inventory" {
"text-blue-400 border-blue-400"
} else {
"text-gray-400 border-transparent hover:text-gray-300"
}
)
on:click=move |_| set_active_tab.set("my_inventory")
>
"My Inventory"
</button>
<button
type="button"
role="tab"
aria-selected=move || active_tab.get() == "server"
class=move || format!(
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
if active_tab.get() == "server" {
"text-blue-400 border-blue-400"
} else {
"text-gray-400 border-transparent hover:text-gray-300"
}
)
on:click=move |_| set_active_tab.set("server")
>
"Server"
</button>
<button
type="button"
role="tab"
aria-selected=move || active_tab.get() == "realm"
class=move || format!(
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
if active_tab.get() == "realm" {
"text-blue-400 border-blue-400"
} else {
"text-gray-400 border-transparent hover:text-gray-300"
}
)
on:click=move |_| set_active_tab.set("realm")
>
"Realm"
</button>
</div>
// Error state // Tab content
<Show when=move || error.get().is_some()> <div class="flex-1 overflow-y-auto min-h-[300px]">
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4"> // My Inventory tab
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p> <Show when=move || active_tab.get() == "my_inventory">
</div> <MyInventoryTab
</Show> items=items
loading=loading
error=error
selected_item=selected_item
set_selected_item=set_selected_item
dropping=dropping
on_drop=Callback::new(handle_drop)
/>
</Show>
// Empty state // Server tab
<Show when=move || !loading.get() && error.get().is_none() && items.get().is_empty()> <Show when=move || active_tab.get() == "server">
<div class="flex flex-col items-center justify-center py-12 text-center"> <PublicPropsTab
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4"> props=server_props
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> loading=server_loading
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/> error=server_error
</svg> tab_name="Server"
</div> empty_message="No public server props available"
<p class="text-gray-400">"Your inventory is empty"</p> />
<p class="text-gray-500 text-sm mt-1">"Collect props to see them here"</p> </Show>
</div>
</Show>
// Grid of items // Realm tab
<Show when=move || !loading.get() && !items.get().is_empty()> <Show when=move || active_tab.get() == "realm">
<div class="flex-1 overflow-y-auto"> <PublicPropsTab
<div props=realm_props
class="grid grid-cols-4 sm:grid-cols-6 gap-2" loading=realm_loading
role="listbox" error=realm_error
aria-label="Inventory items" tab_name="Realm"
> empty_message="No public realm props available"
<For />
each=move || items.get() </Show>
key=|item| item.id </div>
children=move |item: InventoryItem| {
let item_id = item.id;
let item_name = item.prop_name.clone();
let is_selected = move || selected_item.get() == Some(item_id);
let asset_path = if item.prop_asset_path.starts_with('/') {
item.prop_asset_path.clone()
} else {
format!("/static/{}", item.prop_asset_path)
};
view! {
<button
type="button"
class=move || format!(
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
if is_selected() {
"border-blue-500 bg-blue-900/30"
} else {
"border-gray-600 hover:border-gray-500 bg-gray-700/50"
}
)
on:click=move |_| {
set_selected_item.set(Some(item_id));
}
role="option"
aria-selected=is_selected
aria-label=item_name
>
<img
src=asset_path
alt=""
class="w-full h-full object-contain"
/>
</button>
}
}
/>
</div>
</div>
</Show>
// 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! {
<div class="mt-4 pt-4 border-t border-gray-700">
<div class="flex items-center justify-between">
<div>
<h3 class="text-white font-medium">{item.prop_name.clone()}</h3>
<p class="text-gray-400 text-sm">
{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" }}
</p>
</div>
<div class="flex gap-2">
// Drop button - disabled for non-droppable (essential) props
<button
type="button"
class=if is_droppable {
"px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors disabled:opacity-50"
} else {
"px-4 py-2 bg-gray-600 text-gray-400 rounded-lg cursor-not-allowed"
}
on:click=move |_| {
if is_droppable {
handle_drop(item_id);
}
}
disabled=is_dropping || !is_droppable
title=if is_droppable { "" } else { "Essential prop cannot be dropped" }
>
{if is_dropping { "Dropping..." } else { "Drop" }}
</button>
// Transfer button (disabled for now)
<Show when=move || item.is_transferable>
<button
type="button"
class="px-4 py-2 bg-gray-600 text-gray-400 rounded-lg cursor-not-allowed"
disabled=true
title="Transfer functionality coming soon"
>
"Transfer"
</button>
</Show>
</div>
</div>
</div>
})
}}
</div> </div>
</div> </div>
</Show> </Show>
} }
} }
/// My Inventory tab content with drop functionality.
#[component]
fn MyInventoryTab(
#[prop(into)] items: Signal<Vec<InventoryItem>>,
#[prop(into)] loading: Signal<bool>,
#[prop(into)] error: Signal<Option<String>>,
#[prop(into)] selected_item: Signal<Option<Uuid>>,
set_selected_item: WriteSignal<Option<Uuid>>,
#[prop(into)] dropping: Signal<bool>,
#[prop(into)] on_drop: Callback<Uuid>,
) -> impl IntoView {
view! {
// Loading state
<Show when=move || loading.get()>
<div class="flex items-center justify-center py-12">
<p class="text-gray-400">"Loading inventory..."</p>
</div>
</Show>
// Error state
<Show when=move || error.get().is_some()>
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4">
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p>
</div>
</Show>
// Empty state
<Show when=move || !loading.get() && error.get().is_none() && items.get().is_empty()>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<p class="text-gray-400">"Your inventory is empty"</p>
<p class="text-gray-500 text-sm mt-1">"Collect props to see them here"</p>
</div>
</Show>
// Grid of items
<Show when=move || !loading.get() && !items.get().is_empty()>
<div>
<div
class="grid grid-cols-4 sm:grid-cols-6 gap-2"
role="listbox"
aria-label="Inventory items"
>
<For
each=move || items.get()
key=|item| item.id
children=move |item: InventoryItem| {
let item_id = item.id;
let item_name = item.prop_name.clone();
let is_selected = move || selected_item.get() == Some(item_id);
let asset_path = if item.prop_asset_path.starts_with('/') {
item.prop_asset_path.clone()
} else {
format!("/static/{}", item.prop_asset_path)
};
view! {
<button
type="button"
class=move || format!(
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
if is_selected() {
"border-blue-500 bg-blue-900/30"
} else {
"border-gray-600 hover:border-gray-500 bg-gray-700/50"
}
)
on:click=move |_| {
set_selected_item.set(Some(item_id));
}
role="option"
aria-selected=is_selected
aria-label=item_name
>
<img
src=asset_path
alt=""
class="w-full h-full object-contain"
/>
</button>
}
}
/>
</div>
// 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! {
<div class="mt-4 pt-4 border-t border-gray-700">
<div class="flex items-center justify-between">
<div>
<h3 class="text-white font-medium">{item.prop_name.clone()}</h3>
<p class="text-gray-400 text-sm">
{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" }}
</p>
</div>
<div class="flex gap-2">
// Drop button - disabled for non-droppable (essential) props
<button
type="button"
class=if is_droppable {
"px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors disabled:opacity-50"
} else {
"px-4 py-2 bg-gray-600 text-gray-400 rounded-lg cursor-not-allowed"
}
on:click=move |_| {
if is_droppable {
on_drop.run(item_id);
}
}
disabled=is_dropping || !is_droppable
title=if is_droppable { "" } else { "Essential prop cannot be dropped" }
>
{if is_dropping { "Dropping..." } else { "Drop" }}
</button>
// Transfer button (disabled for now)
<Show when=move || item.is_transferable>
<button
type="button"
class="px-4 py-2 bg-gray-600 text-gray-400 rounded-lg cursor-not-allowed"
disabled=true
title="Transfer functionality coming soon"
>
"Transfer"
</button>
</Show>
</div>
</div>
</div>
})
}}
</div>
</Show>
}
}
/// Public props tab content (read-only display).
#[component]
fn PublicPropsTab(
#[prop(into)] props: Signal<Vec<PublicProp>>,
#[prop(into)] loading: Signal<bool>,
#[prop(into)] error: Signal<Option<String>>,
tab_name: &'static str,
empty_message: &'static str,
) -> impl IntoView {
// Selected prop for showing details
let (selected_prop, set_selected_prop) = signal(Option::<Uuid>::None);
view! {
// Loading state
<Show when=move || loading.get()>
<div class="flex items-center justify-center py-12">
<p class="text-gray-400">{format!("Loading {} props...", tab_name.to_lowercase())}</p>
</div>
</Show>
// Error state
<Show when=move || error.get().is_some()>
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4">
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p>
</div>
</Show>
// Empty state
<Show when=move || !loading.get() && error.get().is_none() && props.get().is_empty()>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<p class="text-gray-400">{empty_message}</p>
<p class="text-gray-500 text-sm mt-1">"Public props will appear here when available"</p>
</div>
</Show>
// Grid of props
<Show when=move || !loading.get() && !props.get().is_empty()>
<div>
<div
class="grid grid-cols-4 sm:grid-cols-6 gap-2"
role="listbox"
aria-label=format!("{} props", tab_name)
>
<For
each=move || props.get()
key=|prop| prop.id
children=move |prop: PublicProp| {
let prop_id = prop.id;
let prop_name = prop.name.clone();
let is_selected = move || selected_prop.get() == Some(prop_id);
let asset_path = if prop.asset_path.starts_with('/') {
prop.asset_path.clone()
} else {
format!("/static/{}", prop.asset_path)
};
view! {
<button
type="button"
class=move || format!(
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
if is_selected() {
"border-blue-500 bg-blue-900/30"
} else {
"border-gray-600 hover:border-gray-500 bg-gray-700/50"
}
)
on:click=move |_| {
set_selected_prop.set(Some(prop_id));
}
role="option"
aria-selected=is_selected
aria-label=prop_name
>
<img
src=asset_path
alt=""
class="w-full h-full object-contain"
/>
</button>
}
}
/>
</div>
// 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! {
<div class="mt-4 pt-4 border-t border-gray-700">
<div class="flex items-center justify-between">
<div>
<h3 class="text-white font-medium">{prop.name.clone()}</h3>
{prop.description.map(|desc| view! {
<p class="text-gray-400 text-sm">{desc}</p>
})}
</div>
<p class="text-gray-500 text-sm italic">"View only"</p>
</div>
</div>
})
}}
</div>
</Show>
}
}

View file

@ -709,6 +709,7 @@ pub fn RealmPage() -> impl IntoView {
set_inventory_open.set(false); set_inventory_open.set(false);
}) })
ws_sender=ws_sender_for_inv ws_sender=ws_sender_for_inv
realm_slug=Signal::derive(move || slug.get())
/> />
} }
} }

View file

@ -9,9 +9,9 @@ BEGIN
FOR v_user IN SELECT id, username FROM auth.users FOR v_user IN SELECT id, username FROM auth.users
LOOP LOOP
-- Clear existing data -- Clear existing data
DELETE FROM props.active_avatars WHERE user_id = v_user.id; DELETE FROM auth.active_avatars WHERE user_id = v_user.id;
DELETE FROM props.avatars WHERE user_id = v_user.id; DELETE FROM auth.avatars WHERE user_id = v_user.id;
DELETE FROM props.inventory WHERE user_id = v_user.id; DELETE FROM auth.inventory WHERE user_id = v_user.id;
-- Reinitialize with current server props -- Reinitialize with current server props
PERFORM auth.initialize_new_user(v_user.id); PERFORM auth.initialize_new_user(v_user.id);

View file

@ -16,9 +16,9 @@ SELECT id, username FROM auth.users WHERE username = 'TARGET_USERNAME';
BEGIN; BEGIN;
-- Clear existing props and avatars for the user -- Clear existing props and avatars for the user
DELETE FROM props.active_avatars WHERE user_id = 'USER_UUID'; DELETE FROM auth.active_avatars WHERE user_id = 'USER_UUID';
DELETE FROM props.avatars WHERE user_id = 'USER_UUID'; DELETE FROM auth.avatars WHERE user_id = 'USER_UUID';
DELETE FROM props.inventory WHERE user_id = 'USER_UUID'; DELETE FROM auth.inventory WHERE user_id = 'USER_UUID';
-- Reinitialize with current server props -- Reinitialize with current server props
SELECT auth.initialize_new_user('USER_UUID'); SELECT auth.initialize_new_user('USER_UUID');
@ -29,8 +29,8 @@ COMMIT;
3. Verify the results: 3. Verify the results:
```sql ```sql
SELECT COUNT(*) as inventory_count FROM props.inventory WHERE user_id = 'USER_UUID'; SELECT COUNT(*) as inventory_count FROM auth.inventory WHERE user_id = 'USER_UUID';
SELECT id, name, slot_number FROM props.avatars WHERE user_id = 'USER_UUID'; SELECT id, name, slot_number FROM auth.avatars WHERE user_id = 'USER_UUID';
``` ```
## Example: Reinitialize ranosh ## Example: Reinitialize ranosh
@ -39,9 +39,9 @@ SELECT id, name, slot_number FROM props.avatars WHERE user_id = 'USER_UUID';
psql -d chattyness <<'EOF' psql -d chattyness <<'EOF'
BEGIN; BEGIN;
DELETE FROM props.active_avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4'; DELETE FROM auth.active_avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4';
DELETE FROM props.avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4'; DELETE FROM auth.avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4';
DELETE FROM props.inventory 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'); SELECT auth.initialize_new_user('57a12201-ea0f-4545-9ccc-c4e67ea7e2c4');

View file

@ -278,6 +278,7 @@ CREATE TABLE server.props (
is_transferable BOOLEAN NOT NULL DEFAULT true, is_transferable BOOLEAN NOT NULL DEFAULT true,
is_portable BOOLEAN NOT NULL DEFAULT true, is_portable BOOLEAN NOT NULL DEFAULT true,
is_droppable 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, is_active BOOLEAN NOT NULL DEFAULT true,
available_from TIMESTAMPTZ, 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_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_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 -- Audio Library

View file

@ -216,6 +216,7 @@ CREATE TABLE realm.props (
is_unique BOOLEAN NOT NULL DEFAULT false, is_unique BOOLEAN NOT NULL DEFAULT false,
is_transferable BOOLEAN NOT NULL DEFAULT true, is_transferable BOOLEAN NOT NULL DEFAULT true,
is_droppable 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, is_active BOOLEAN NOT NULL DEFAULT true,
available_from TIMESTAMPTZ, 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_realm ON realm.props (realm_id);
CREATE INDEX idx_realm_props_tags ON realm.props USING GIN (tags); 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_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) -- Add Foreign Keys to auth tables (now that realm.realms and realm.props exist)

View file

@ -111,12 +111,13 @@ for file in "$PROPS_DIR"/*.svg; do
echo "Uploading: $filename -> $display_name (category: $category)" 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 <<EOF metadata=$(cat <<EOF
{ {
"name": "$display_name", "name": "$display_name",
"tags": $tags, "tags": $tags,
"droppable": true "droppable": true,
"public": true
} }
EOF EOF
) )