feat: profiles and /set profile, and id cards

* New functionality to set meta data on businesscards.
* Can develop a user profile.
* Business cards link to user profile.
This commit is contained in:
Evan Carroll 2026-01-25 10:50:10 -06:00
parent cd8dfb94a3
commit 710985638f
35 changed files with 4932 additions and 435 deletions

View file

@ -3,6 +3,7 @@
pub mod auth;
pub mod avatars;
pub mod inventory;
pub mod profile;
pub mod realms;
pub mod routes;
pub mod scenes;

View file

@ -0,0 +1,286 @@
//! Profile API handlers.
//!
//! Endpoints for user profile management including basic info,
//! visibility settings, contacts, and organizations.
use axum::{Json, extract::{Path, State}};
use sqlx::PgPool;
use uuid::Uuid;
use chattyness_db::{
models::{
CreateContactRequest, CreateOrganizationRequest, ProfileVisibility, PublicProfile,
UpdateContactRequest, UpdateOrganizationRequest, UpdateProfileRequest,
UpdateVisibilityRequest, UserContact, UserOrganization, UserProfile,
},
queries::users,
};
use chattyness_error::AppError;
use crate::auth::{AuthUser, OptionalAuthUser, RlsConn};
// =============================================================================
// Response Types
// =============================================================================
/// Success response for profile operations.
#[derive(Debug, serde::Serialize)]
pub struct SuccessResponse {
pub success: bool,
}
/// Response with created resource ID.
#[derive(Debug, serde::Serialize)]
pub struct CreatedResponse {
pub id: Uuid,
}
// =============================================================================
// Profile Endpoints
// =============================================================================
/// Get the current user's profile.
pub async fn get_profile(
State(pool): State<PgPool>,
AuthUser(user): AuthUser,
) -> Result<Json<UserProfile>, AppError> {
let profile = users::get_user_profile(&pool, user.id)
.await?
.ok_or_else(|| AppError::NotFound("Profile not found".to_string()))?;
Ok(Json(profile))
}
/// Update the current user's basic profile fields.
pub async fn update_profile(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Json(req): Json<UpdateProfileRequest>,
) -> Result<Json<SuccessResponse>, AppError> {
// Validate the request
req.validate()?;
// Update profile using RLS connection
let mut conn = rls_conn.acquire().await;
users::update_user_profile_conn(&mut *conn, user.id, &req).await?;
Ok(Json(SuccessResponse { success: true }))
}
/// Update the current user's visibility settings.
pub async fn update_visibility(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Json(req): Json<UpdateVisibilityRequest>,
) -> Result<Json<SuccessResponse>, AppError> {
// Update visibility using RLS connection
let mut conn = rls_conn.acquire().await;
users::update_visibility_conn(
&mut *conn,
user.id,
req.profile_visibility,
req.contacts_visibility,
req.organizations_visibility,
req.avatar_source,
)
.await?;
Ok(Json(SuccessResponse { success: true }))
}
// =============================================================================
// Contact Endpoints
// =============================================================================
/// List all contacts for the current user.
pub async fn list_contacts(
State(pool): State<PgPool>,
AuthUser(user): AuthUser,
) -> Result<Json<Vec<UserContact>>, AppError> {
let contacts = users::list_user_contacts(&pool, user.id).await?;
Ok(Json(contacts))
}
/// Create a new contact.
pub async fn create_contact(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Json(req): Json<CreateContactRequest>,
) -> Result<Json<CreatedResponse>, AppError> {
// Validate the request
req.validate()?;
// Create contact using RLS connection
let mut conn = rls_conn.acquire().await;
let contact_id = users::create_contact_conn(&mut *conn, user.id, &req).await?;
Ok(Json(CreatedResponse { id: contact_id }))
}
/// Update an existing contact.
pub async fn update_contact(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Path(contact_id): Path<Uuid>,
Json(req): Json<UpdateContactRequest>,
) -> Result<Json<SuccessResponse>, AppError> {
// Validate the request
req.validate()?;
// Update contact using RLS connection
let mut conn = rls_conn.acquire().await;
users::update_contact_conn(&mut *conn, user.id, contact_id, &req).await?;
Ok(Json(SuccessResponse { success: true }))
}
/// Delete a contact.
pub async fn delete_contact(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Path(contact_id): Path<Uuid>,
) -> Result<Json<SuccessResponse>, AppError> {
// Delete contact using RLS connection
let mut conn = rls_conn.acquire().await;
users::delete_contact_conn(&mut *conn, user.id, contact_id).await?;
Ok(Json(SuccessResponse { success: true }))
}
// =============================================================================
// Organization Endpoints
// =============================================================================
/// List all organizations for the current user.
pub async fn list_organizations(
State(pool): State<PgPool>,
AuthUser(user): AuthUser,
) -> Result<Json<Vec<UserOrganization>>, AppError> {
let organizations = users::list_user_organizations(&pool, user.id).await?;
Ok(Json(organizations))
}
/// Create a new organization.
pub async fn create_organization(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Json(req): Json<CreateOrganizationRequest>,
) -> Result<Json<CreatedResponse>, AppError> {
// Validate the request
req.validate()?;
// Create organization using RLS connection
let mut conn = rls_conn.acquire().await;
let org_id = users::create_organization_conn(&mut *conn, user.id, &req).await?;
Ok(Json(CreatedResponse { id: org_id }))
}
/// Update an existing organization.
pub async fn update_organization(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Path(org_id): Path<Uuid>,
Json(req): Json<UpdateOrganizationRequest>,
) -> Result<Json<SuccessResponse>, AppError> {
// Validate the request
req.validate()?;
// Update organization using RLS connection
let mut conn = rls_conn.acquire().await;
users::update_organization_conn(&mut *conn, user.id, org_id, &req).await?;
Ok(Json(SuccessResponse { success: true }))
}
/// Delete an organization.
pub async fn delete_organization(
rls_conn: RlsConn,
AuthUser(user): AuthUser,
Path(org_id): Path<Uuid>,
) -> Result<Json<SuccessResponse>, AppError> {
// Delete organization using RLS connection
let mut conn = rls_conn.acquire().await;
users::delete_organization_conn(&mut *conn, user.id, org_id).await?;
Ok(Json(SuccessResponse { success: true }))
}
// =============================================================================
// Public Profile Endpoints
// =============================================================================
/// Get a user's public profile by username.
/// Visibility settings determine what data is returned:
/// - Public: visible to everyone
/// - Members: visible to logged-in users
/// - Friends: visible to user's friends (not yet implemented, treated as private)
/// - Private: visible only to the profile owner
pub async fn get_public_profile(
State(pool): State<PgPool>,
OptionalAuthUser(maybe_user): OptionalAuthUser,
Path(username): Path<String>,
) -> Result<Json<PublicProfile>, AppError> {
// Fetch the user profile
let profile = users::get_public_profile_by_username(&pool, &username)
.await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
let viewer_id = maybe_user.as_ref().map(|u| u.id);
let is_owner = viewer_id == Some(profile.id);
let is_authenticated = viewer_id.is_some();
// Check if viewer can see the profile at all
if !can_view(&profile.profile_visibility, is_owner, is_authenticated) {
return Err(AppError::NotFound("User not found".to_string()));
}
// Determine what to show based on visibility settings
let contacts = if can_view(&profile.contacts_visibility, is_owner, is_authenticated) {
Some(users::list_user_contacts(&pool, profile.id).await?)
} else {
None
};
let organizations = if can_view(&profile.organizations_visibility, is_owner, is_authenticated) {
Some(users::list_user_organizations(&pool, profile.id).await?)
} else {
None
};
// Email and phone follow contacts visibility
let (email, phone) = if can_view(&profile.contacts_visibility, is_owner, is_authenticated) {
(profile.email, profile.phone)
} else {
(None, None)
};
Ok(Json(PublicProfile {
id: profile.id,
username: profile.username,
email,
phone,
display_name: profile.display_name,
summary: profile.summary,
homepage: profile.homepage,
bio: profile.bio,
avatar_source: profile.avatar_source,
contacts,
organizations,
member_since: profile.created_at,
is_owner,
}))
}
/// Check if a viewer can see content based on visibility setting.
fn can_view(visibility: &ProfileVisibility, is_owner: bool, is_authenticated: bool) -> bool {
if is_owner {
return true;
}
match visibility {
ProfileVisibility::Public => true,
ProfileVisibility::Members => is_authenticated,
ProfileVisibility::Friends => false, // TODO: implement friend checking
ProfileVisibility::Private => false,
}
}

View file

@ -6,7 +6,7 @@
use axum::{Router, routing::get};
use super::{auth, avatars, inventory, realms, scenes, websocket};
use super::{auth, avatars, inventory, profile, realms, scenes, websocket};
use crate::app::AppState;
/// Build the API router for user UI.
@ -35,6 +35,35 @@ pub fn api_router() -> Router<AppState> {
"/auth/preferences",
axum::routing::put(auth::update_preferences),
)
// Profile routes
.route(
"/profile",
get(profile::get_profile).put(profile::update_profile),
)
.route(
"/profile/visibility",
axum::routing::put(profile::update_visibility),
)
.route(
"/profile/contacts",
get(profile::list_contacts).post(profile::create_contact),
)
.route(
"/profile/contacts/{id}",
axum::routing::put(profile::update_contact)
.delete(profile::delete_contact),
)
.route(
"/profile/organizations",
get(profile::list_organizations).post(profile::create_organization),
)
.route(
"/profile/organizations/{id}",
axum::routing::put(profile::update_organization)
.delete(profile::delete_organization),
)
// Public profile route (no auth required for public profiles)
.route("/users/{username}", get(profile::get_public_profile))
// Realm routes (READ-ONLY)
.route("/realms", get(realms::list_realms))
.route("/realms/{slug}", get(realms::get_realm))

View file

@ -711,6 +711,80 @@ async fn handle_socket(
}
}
}
ClientMessage::CopyAndDropProp { inventory_item_id } => {
// Ensure instance exists for this scene (required for loose_props FK)
// In this system, channel_id = scene_id
if let Err(e) = loose_props::ensure_scene_instance(
&mut *recv_conn,
channel_id,
)
.await
{
tracing::error!(
"[WS] Failed to ensure scene instance: {:?}",
e
);
}
// Get user's current position for random offset
let member_info = channel_members::get_channel_member(
&mut *recv_conn,
channel_id,
user_id,
realm_id,
)
.await;
if let Ok(Some(member)) = member_info {
// Generate random offset (within ~50 pixels)
let offset_x = (rand::random::<f64>() - 0.5) * 100.0;
let offset_y = (rand::random::<f64>() - 0.5) * 100.0;
let pos_x = member.position_x + offset_x;
let pos_y = member.position_y + offset_y;
match loose_props::copy_and_drop_prop(
&mut *recv_conn,
inventory_item_id,
user_id,
channel_id,
pos_x,
pos_y,
)
.await
{
Ok(prop) => {
#[cfg(debug_assertions)]
tracing::debug!(
"[WS] User {} copied and dropped prop {} at ({}, {})",
user_id,
prop.id,
pos_x,
pos_y
);
let _ =
tx.send(ServerMessage::PropDropped { prop });
}
Err(e) => {
tracing::error!("[WS] Copy and drop prop failed: {:?}", e);
let (code, message) = match &e {
chattyness_error::AppError::Forbidden(msg) => (
"PROP_NOT_COPYABLE".to_string(),
msg.clone(),
),
chattyness_error::AppError::NotFound(msg) => {
("PROP_NOT_FOUND".to_string(), msg.clone())
}
_ => (
"COPY_DROP_FAILED".to_string(),
format!("{:?}", e),
),
};
let _ =
tx.send(ServerMessage::Error { code, message });
}
}
}
}
ClientMessage::DeleteProp { inventory_item_id } => {
match inventory::drop_inventory_item(
&mut *recv_conn,
@ -1432,11 +1506,13 @@ async fn handle_socket(
// Update the is_guest flag - critical for allowing
// newly registered users to send whispers
is_guest = updated_user.is_guest();
let username = updated_user.username.clone();
let display_name = updated_user.display_name.clone();
tracing::info!(
"[WS] User {} refreshed identity: display_name={}, is_guest={}",
"[WS] User {} refreshed identity: username={}, display_name={}, is_guest={}",
user_id,
username,
display_name,
is_guest
);
@ -1457,6 +1533,7 @@ async fn handle_socket(
// Broadcast identity update to all channel members
let _ = tx.send(ServerMessage::MemberIdentityUpdated {
user_id,
username,
display_name,
is_guest,
});
@ -1758,7 +1835,11 @@ async fn handle_socket(
// 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);
let action_hint = PropStateView::detect_action_hint_full(
&prop.server_state,
None,
Some(&prop.prop_name),
);
// 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
@ -1768,11 +1849,18 @@ async fn handle_socket(
} else {
None
};
// Clear is_owner_card flag for loose props - this flag
// is only meaningful for inventory items (the owner's copy).
// Loose props should never show "this is your card" message.
let mut server_state = prop.server_state;
if let Some(obj) = server_state.as_object_mut() {
obj.remove("is_owner_card");
}
Ok(PropStateView {
prop_id,
prop_name: prop.prop_name,
owner_display_name: owner_name,
server_state: prop.server_state,
server_state,
// Realm state visible if user is in same realm
realm_state: Some(prop.realm_state),
user_state: prop.user_state,
@ -1788,7 +1876,12 @@ async fn handle_socket(
// 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);
// Use detect_action_hint_with_private to detect business cards
// from server_private_state (received cards have snapshot there)
let action_hint = PropStateView::detect_action_hint_with_private(
&item.server_state,
Some(&item.server_private_state),
);
Ok(PropStateView {
prop_id,
prop_name: item.prop_name,

View file

@ -21,6 +21,8 @@ enum CommandMode {
ShowingSlashHint,
/// Showing mod command hints only (`/mod summon [nick|*]`).
ShowingModHint,
/// Showing set command hints (`/set profile`).
ShowingSetHint,
/// Showing emotion list popup.
ShowingList,
/// Showing scene list popup for teleport.
@ -183,6 +185,9 @@ pub fn ChatInput(
/// Callback to open registration modal.
#[prop(optional)]
on_open_register: Option<Callback<()>>,
/// Callback to open profile page (non-guests only).
#[prop(optional)]
on_open_profile: Option<Callback<()>>,
) -> impl IntoView {
let (message, set_message) = signal(String::new());
let (command_mode, set_command_mode) = signal(CommandMode::None);
@ -382,9 +387,25 @@ pub fn ChatInput(
&& !cmd.is_empty()
&& "register".starts_with(&cmd);
// Check if typing set command (only for non-guests)
// Show set hint when typing "/set" or "/set ..."
let is_typing_set = !is_guest.get_untracked()
&& (cmd == "set" || cmd.starts_with("set "));
// Show /set in slash hints when just starting to type it (but not if closer to /setting)
// Only show when typing exactly "se" to avoid conflict with /setting
let is_partial_set = !is_guest.get_untracked()
&& !cmd.is_empty()
&& cmd.len() >= 2
&& "set".starts_with(&cmd)
&& cmd != "set"
&& cmd != "s"; // /s goes to /setting
if is_complete_whisper || is_complete_teleport {
// User is typing the argument part, no hint needed
set_command_mode.set(CommandMode::None);
} else if is_typing_set {
// Show set-specific hint bar
set_command_mode.set(CommandMode::ShowingSetHint);
} else if is_typing_mod {
// Show mod-specific hint bar
set_command_mode.set(CommandMode::ShowingModHint);
@ -404,6 +425,7 @@ pub fn ChatInput(
|| cmd.starts_with("teleport ")
|| is_partial_mod
|| is_partial_register
|| is_partial_set
{
set_command_mode.set(CommandMode::ShowingSlashHint);
} else {
@ -586,6 +608,21 @@ pub fn ChatInput(
ev.prevent_default();
return;
}
// Autocomplete /set p to /set profile (only for non-guests)
if !is_guest.get_untracked() && cmd.starts_with("set ") {
let subcommand = cmd.strip_prefix("set ").unwrap_or("");
if !subcommand.is_empty()
&& "profile".starts_with(subcommand)
&& subcommand != "profile"
{
set_message.set("/set profile".to_string());
if let Some(input) = input_ref.get() {
input.set_value("/set profile");
}
ev.prevent_default();
return;
}
}
}
// Always prevent Tab from moving focus when in input
ev.prevent_default();
@ -599,6 +636,25 @@ pub fn ChatInput(
if msg.starts_with('/') {
let cmd = msg[1..].to_lowercase();
// /set profile - open profile page (non-guests only)
// Must check before /setting since "set" is a prefix of "setting"
if !is_guest.get_untracked() && cmd.starts_with("set ") {
let subcommand = cmd.strip_prefix("set ").unwrap_or("").trim();
if !subcommand.is_empty() && "profile".starts_with(subcommand) {
if let Some(ref callback) = on_open_profile {
callback.run(());
}
set_message.set(String::new());
set_command_mode.set(CommandMode::None);
if let Some(input) = input_ref.get() {
input.set_value("");
let _ = input.blur();
}
ev.prevent_default();
return;
}
}
// /s, /se, /set, /sett, /setti, /settin, /setting, /settings
if !cmd.is_empty() && ("setting".starts_with(&cmd) || cmd == "settings") {
if let Some(ref callback) = on_open_settings {
@ -883,6 +939,12 @@ pub fn ChatInput(
<span class="text-purple-400">"/"</span>
<span class="text-purple-400">"mod"</span>
</Show>
// Show /set hint for non-guests (details shown when typing /set)
<Show when=move || !is_guest.get()>
<span class="text-gray-600 mx-2">"|"</span>
<span class="text-green-400">"/"</span>
<span class="text-green-400">"set"</span>
</Show>
// Show /register hint for guests
<Show when=move || is_guest.get()>
<span class="text-gray-600 mx-2">"|"</span>
@ -912,6 +974,16 @@ pub fn ChatInput(
</div>
</Show>
// Set command hint bar (shown when typing /set)
<Show when=move || command_mode.get() == CommandMode::ShowingSetHint>
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-green-900/90 backdrop-blur-sm rounded text-sm">
<span class="text-green-400">"/"</span>
<span class="text-green-400">"set"</span>
<span class="text-green-300">" profile"</span>
<span class="text-green-500">" - edit your profile"</span>
</div>
</Show>
// Emotion list popup
<Show when=move || command_mode.get() == CommandMode::ShowingList>
<EmoteListPopup

View file

@ -351,3 +351,441 @@ fn event_target_checked(ev: &leptos::ev::Event) -> bool {
fn event_target_checked(_ev: &leptos::ev::Event) -> bool {
false
}
/// Select input (dropdown) field with label.
#[component]
pub fn SelectInput(
name: &'static str,
label: &'static str,
options: Vec<(&'static str, &'static str)>,
#[prop(optional)] help_text: &'static str,
#[prop(default = false)] required: bool,
#[prop(optional)] class: &'static str,
#[prop(into)] value: Signal<String>,
on_change: Callback<String>,
) -> impl IntoView {
let input_id = name;
let help_id = format!("{}-help", name);
let has_help = !help_text.is_empty();
view! {
<div class=format!("space-y-2 {}", class)>
<label for=input_id class="block text-sm font-medium text-gray-300">
{label}
{if required {
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
} else {
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
}}
</label>
<select
id=input_id
name=name
required=required
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
class="input-base"
on:change=move |ev| on_change.run(event_target_value(&ev))
>
{options
.into_iter()
.map(|(val, display)| {
let is_selected = Signal::derive(move || value.get() == val);
view! { <option value=val selected=move || is_selected.get()>{display}</option> }
})
.collect_view()}
</select>
{if has_help {
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
} else {
view! {}.into_any()
}}
</div>
}
}
/// Grouped select input (dropdown with optgroups) field with label.
#[component]
pub fn GroupedSelectInput(
name: &'static str,
label: &'static str,
groups: Vec<(&'static str, Vec<(&'static str, &'static str)>)>,
#[prop(optional)] help_text: &'static str,
#[prop(default = false)] required: bool,
#[prop(optional)] class: &'static str,
#[prop(into)] value: Signal<String>,
on_change: Callback<String>,
) -> impl IntoView {
let input_id = name;
let help_id = format!("{}-help", name);
let has_help = !help_text.is_empty();
view! {
<div class=format!("space-y-2 {}", class)>
<label for=input_id class="block text-sm font-medium text-gray-300">
{label}
{if required {
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
} else {
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
}}
</label>
<select
id=input_id
name=name
required=required
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
class="input-base"
on:change=move |ev| on_change.run(event_target_value(&ev))
>
{groups
.into_iter()
.map(|(group_label, options)| {
view! {
<optgroup label=group_label>
{options
.into_iter()
.map(|(val, display)| {
let is_selected = Signal::derive(move || value.get() == val);
view! {
<option value=val selected=move || is_selected.get()>
{display}
</option>
}
})
.collect_view()}
</optgroup>
}
})
.collect_view()}
</select>
{if has_help {
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
} else {
view! {}.into_any()
}}
</div>
}
}
/// Searchable select with grouped options (combobox pattern).
/// Allows typing to filter options and selecting from grouped dropdown.
#[component]
pub fn SearchableSelect(
name: &'static str,
label: &'static str,
groups: Vec<(&'static str, Vec<(&'static str, &'static str)>)>,
#[prop(optional)] help_text: &'static str,
#[prop(default = false)] required: bool,
#[prop(optional)] class: &'static str,
#[prop(into)] value: Signal<String>,
on_change: Callback<String>,
) -> impl IntoView {
let input_id = name;
let listbox_id = format!("{}-listbox", name);
let help_id = format!("{}-help", name);
let has_help = !help_text.is_empty();
// State for the search/filter text
let (search_text, set_search_text) = signal(String::new());
let (is_open, set_is_open) = signal(false);
let (focused_index, set_focused_index) = signal(Option::<usize>::None);
// Clone groups for use in multiple closures
let groups_for_label = groups.clone();
let groups_for_filter = groups.clone();
// Get the display label for current value
let display_label = Signal::derive(move || {
let current = value.get();
for (_, opts) in groups_for_label.iter() {
for (val, label) in opts.iter() {
if *val == current.as_str() {
return label.to_string();
}
}
}
current
});
// Filter options based on search text
let filtered_groups = Signal::derive(move || {
let search = search_text.get().to_lowercase();
if search.is_empty() {
return groups_for_filter.clone();
}
groups_for_filter
.iter()
.filter_map(|(group_label, options)| {
let filtered: Vec<_> = options
.iter()
.filter(|(_, label)| label.to_lowercase().contains(&search))
.cloned()
.collect();
if filtered.is_empty() {
None
} else {
Some((*group_label, filtered))
}
})
.collect()
});
// Count total filtered options for keyboard navigation
let filtered_count = Signal::derive(move || {
filtered_groups
.get()
.iter()
.map(|(_, opts)| opts.len())
.sum::<usize>()
});
// Get the option at a given flat index from filtered results
let get_option_at_index = move |index: usize| -> Option<(&'static str, &'static str)> {
let groups = filtered_groups.get();
let mut current = 0;
for (_, opts) in groups.iter() {
for (val, label) in opts.iter() {
if current == index {
return Some((*val, *label));
}
current += 1;
}
}
None
};
let handle_select = move |val: &str| {
on_change.run(val.to_string());
set_search_text.set(String::new());
set_is_open.set(false);
set_focused_index.set(None);
};
view! {
<div class=format!("space-y-2 relative {}", class)>
<label for=input_id class="block text-sm font-medium text-gray-300">
{label}
{if required {
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
} else {
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
}}
</label>
// Hidden select for form submission
<input type="hidden" name=name prop:value=move || value.get() />
// Combobox input
<div class="relative">
<input
type="text"
id=input_id
role="combobox"
aria-expanded=move || is_open.get()
aria-haspopup="listbox"
aria-controls=listbox_id.clone()
aria-autocomplete="list"
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
autocomplete="off"
class="input-base pr-8"
placeholder=move || display_label.get()
prop:value=move || {
if is_open.get() {
search_text.get()
} else {
display_label.get()
}
}
on:focus=move |_| {
set_is_open.set(true);
set_search_text.set(String::new());
}
on:blur=move |_| {
// Delay to allow click events on options
#[cfg(feature = "hydrate")]
{
use gloo_timers::callback::Timeout;
Timeout::new(150, move || {
set_is_open.set(false);
set_search_text.set(String::new());
set_focused_index.set(None);
}).forget();
}
}
on:input=move |ev| {
set_search_text.set(event_target_value(&ev));
set_is_open.set(true);
set_focused_index.set(if filtered_count.get() > 0 { Some(0) } else { None });
}
on:keydown=move |ev| {
let key = ev.key();
match key.as_str() {
"ArrowDown" => {
ev.prevent_default();
let count = filtered_count.get();
if count > 0 {
set_is_open.set(true);
set_focused_index.update(|idx| {
*idx = Some(idx.map(|i| (i + 1) % count).unwrap_or(0));
});
}
}
"ArrowUp" => {
ev.prevent_default();
let count = filtered_count.get();
if count > 0 {
set_focused_index.update(|idx| {
*idx = Some(idx.map(|i| if i == 0 { count - 1 } else { i - 1 }).unwrap_or(count - 1));
});
}
}
"Enter" => {
ev.prevent_default();
if let Some(idx) = focused_index.get() {
if let Some((val, _)) = get_option_at_index(idx) {
handle_select(val);
}
}
}
"Escape" => {
set_is_open.set(false);
set_search_text.set(String::new());
set_focused_index.set(None);
}
_ => {}
}
}
/>
// Dropdown arrow indicator
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
// Dropdown listbox
<Show when=move || is_open.get()>
<ul
id=listbox_id.clone()
role="listbox"
class="absolute z-50 w-full mt-1 max-h-60 overflow-auto bg-gray-800 border border-gray-600 rounded-lg shadow-lg"
>
{move || {
let groups = filtered_groups.get();
if groups.is_empty() {
view! {
<li class="px-3 py-2 text-gray-400 text-sm">"No matching platforms"</li>
}.into_any()
} else {
let mut flat_index = 0usize;
groups
.into_iter()
.map(|(group_label, options)| {
let group_options = options
.into_iter()
.map(|(val, display)| {
let current_index = flat_index;
flat_index += 1;
let is_selected = Signal::derive(move || value.get() == val);
let is_focused = Signal::derive(move || focused_index.get() == Some(current_index));
let val_string = val.to_string();
view! {
<li
role="option"
aria-selected=move || is_selected.get()
class=move || {
let mut classes = "px-3 py-2 cursor-pointer text-sm".to_string();
if is_focused.get() {
classes.push_str(" bg-blue-600 text-white");
} else if is_selected.get() {
classes.push_str(" bg-gray-700 text-blue-400");
} else {
classes.push_str(" text-gray-200 hover:bg-gray-700");
}
classes
}
on:mousedown=move |ev| {
ev.prevent_default();
handle_select(&val_string);
}
on:mouseenter=move |_| {
set_focused_index.set(Some(current_index));
}
>
{display}
</li>
}
})
.collect_view();
view! {
<li role="group">
<div class="px-3 py-1 text-xs font-semibold text-gray-400 bg-gray-900 sticky top-0">
{group_label}
</div>
<ul role="group">
{group_options}
</ul>
</li>
}
})
.collect_view()
.into_any()
}
}}
</ul>
</Show>
{if has_help {
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
} else {
view! {}.into_any()
}}
</div>
}
}
/// Date input field with label.
#[component]
pub fn DateInput(
name: &'static str,
label: &'static str,
#[prop(optional)] help_text: &'static str,
#[prop(default = false)] required: bool,
#[prop(optional)] class: &'static str,
#[prop(into)] value: Signal<String>,
on_change: Callback<String>,
) -> impl IntoView {
let input_id = name;
let help_id = format!("{}-help", name);
let has_help = !help_text.is_empty();
view! {
<div class=format!("space-y-2 {}", class)>
<label for=input_id class="block text-sm font-medium text-gray-300">
{label}
{if required {
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
} else {
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
}}
</label>
<input
type="date"
id=input_id
name=name
required=required
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
class="input-base"
prop:value=move || value.get()
on:input=move |ev| on_change.run(event_target_value(&ev))
/>
{if has_help {
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
} else {
view! {}.into_any()
}}
</div>
}
}

View file

@ -4,10 +4,9 @@ use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
use uuid::Uuid;
use chattyness_db::models::{InventoryItem, PropAcquisitionInfo, StateScope, StateVisibility};
use chattyness_db::models::{InventoryItem, PropAcquisitionInfo};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
use leptos::ev::Event;
use super::modals::{ConfirmModal, GuestLockedOverlay, Modal};
use super::tabs::{Tab, TabBar};
@ -35,6 +34,9 @@ pub fn InventoryPopup(
/// Whether the current user is a guest. Guests see a locked overlay.
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
/// Callback when user wants to view an inventory item's state (e.g., business card info)
#[prop(optional, into)]
on_view_item_state: Option<Callback<Uuid>>,
) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Tab state
@ -291,6 +293,32 @@ pub fn InventoryPopup(
#[cfg(not(feature = "hydrate"))]
let handle_drop = |_item_id: Uuid| {};
// Handle copy and drop action via WebSocket (keeps original in inventory)
#[cfg(feature = "hydrate")]
let handle_copy_drop = {
move |item_id: Uuid| {
set_dropping.set(true);
// Send copy and drop command via WebSocket
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::CopyAndDropProp {
inventory_item_id: item_id,
});
// Note: We don't remove from inventory since this is a copy
// The original item stays in inventory
} else {
set_error.set(Some("Not connected to server".to_string()));
}
});
set_dropping.set(false);
}
};
#[cfg(not(feature = "hydrate"))]
let handle_copy_drop = |_item_id: Uuid| {};
// Handle delete action via WebSocket (permanent deletion)
#[cfg(feature = "hydrate")]
let handle_delete = {
@ -368,11 +396,13 @@ pub fn InventoryPopup(
set_selected_item=set_selected_item
dropping=dropping
on_drop=Callback::new(handle_drop)
on_copy_drop=Callback::new(handle_copy_drop)
deleting=deleting
on_delete_request=Callback::new(move |(id, name)| {
set_delete_confirm_item.set(Some((id, name)));
})
on_delete_immediate=Callback::new(handle_delete)
on_view_item_state=on_view_item_state.clone().unwrap_or_else(|| Callback::new(|_| {}))
ws_sender=ws_sender
/>
</Show>
@ -455,10 +485,14 @@ fn MyInventoryTab(
set_selected_item: WriteSignal<Option<Uuid>>,
#[prop(into)] dropping: Signal<bool>,
#[prop(into)] on_drop: Callback<Uuid>,
/// Callback for copy and drop (creates copy on scene, keeps original in inventory)
#[prop(into)] on_copy_drop: Callback<Uuid>,
#[prop(into)] deleting: Signal<bool>,
#[prop(into)] on_delete_request: Callback<(Uuid, String)>,
/// Callback for immediate delete (Shift+Delete, no confirmation)
#[prop(into)] on_delete_immediate: Callback<Uuid>,
/// Callback when user wants to view item state (e.g., business card info)
#[prop(into)] on_view_item_state: Callback<Uuid>,
/// WebSocket sender for updating item state
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
) -> impl IntoView {
@ -498,27 +532,42 @@ fn MyInventoryTab(
let Some(item_id) = selected_item.get() else { return };
let Some(item) = items.get().into_iter().find(|i| i.id == item_id) else { return };
// Only allow actions on droppable items
if !item.is_droppable {
return;
}
let key = ev.key();
let shift = ev.shift_key();
match key.as_str() {
"i" | "I" => {
// View Info: only if item has server_private_state
if !item.server_private_state.is_null() {
ev.prevent_default();
on_view_item_state.run(item_id);
}
}
"d" | "D" => {
ev.prevent_default();
on_drop.run(item_id);
// Drop: only for droppable items
if item.is_droppable {
ev.prevent_default();
on_drop.run(item_id);
}
}
"c" | "C" => {
// Copy & Drop: only for droppable, non-unique items
if item.is_droppable && !item.is_unique {
ev.prevent_default();
on_copy_drop.run(item_id);
}
}
"Delete" => {
ev.prevent_default();
if shift {
// Shift+Delete: immediate delete without confirmation
on_delete_immediate.run(item_id);
} else {
// Delete: delete with confirmation
on_delete_request.run((item_id, item.prop_name.clone()));
// Delete: only for droppable items
if item.is_droppable {
ev.prevent_default();
if shift {
// Shift+Delete: immediate delete without confirmation
on_delete_immediate.run(item_id);
} else {
// Delete: delete with confirmation
on_delete_request.run((item_id, item.prop_name.clone()));
}
}
}
_ => {}
@ -614,11 +663,16 @@ fn MyInventoryTab(
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 on_copy_drop = on_copy_drop.clone();
let on_delete_request = on_delete_request.clone();
let on_view_item_state = on_view_item_state.clone();
let is_dropping = dropping.get();
let is_deleting = deleting.get();
let is_droppable = item.is_droppable;
let is_unique = item.is_unique;
let can_copy_drop = is_droppable && !is_unique;
let item_name = item.prop_name.clone();
let has_state = !item.server_private_state.is_null();
Some(view! {
<div class="mt-4 pt-4 border-t border-gray-700">
@ -629,9 +683,33 @@ fn MyInventoryTab(
{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" }}
{if is_unique { " \u{2022} Unique" } else { "" }}
</p>
</div>
<div class="flex gap-2">
// View Info button - only shown for items with server private state
<Show when=move || has_state>
<button
type="button"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
on:click=move |_| on_view_item_state.run(item_id)
title="View item details (I)"
>
<span>"View "<u>"I"</u>"nfo"</span>
</button>
</Show>
// Copy & Drop button - only shown for droppable, non-unique props
<Show when=move || can_copy_drop>
<button
type="button"
class="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg transition-colors disabled:opacity-50"
on:click=move |_| on_copy_drop.run(item_id)
disabled=is_dropping
title="Drop a copy, keep original (C)"
>
<span><u>"C"</u>"opy & Drop"</span>
</button>
</Show>
// Drop button - disabled for non-droppable (essential) props
<button
type="button"
@ -646,7 +724,7 @@ fn MyInventoryTab(
}
}
disabled=is_dropping || !is_droppable
title=if is_droppable { "Drop prop to scene canvas" } else { "Essential prop cannot be dropped" }
title=if is_droppable { "Drop prop to scene canvas (removes from inventory)" } else { "Essential prop cannot be dropped" }
>
{if is_dropping {
view! { "Dropping..." }.into_any()
@ -682,21 +760,6 @@ 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>
})
}}
@ -706,164 +769,6 @@ 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(

View file

@ -430,8 +430,15 @@ pub fn PropInfoModal(
// Content based on action hint
{match state.action_hint {
Some(PropActionHint::BusinessCard) => {
let server_private_state = state.private_state
.as_ref()
.map(|ps| ps.server_private_state.clone())
.unwrap_or_else(|| serde_json::json!({}));
view! {
<BusinessCardView server_state=state.server_state />
<BusinessCardView
server_state=state.server_state
server_private_state=server_private_state
/>
}.into_any()
}
Some(PropActionHint::ExternalLinks) => {
@ -456,21 +463,62 @@ pub fn PropInfoModal(
/// Business card view for props with profile information.
///
/// Displays profile fields and social media link buttons.
/// Displays profile fields from `server_private_state.snapshot` (captured giver profile).
/// For received cards, shows a "View Profile" button linking to the giver's profile.
#[component]
pub fn BusinessCardView(
server_state: serde_json::Value,
server_private_state: serde_json::Value,
) -> impl IntoView {
// Extract profile from server_state
let profile = server_state.get("profile").cloned().unwrap_or(serde_json::Value::Null);
// Check if this is the owner's card (acquired from library, not received from someone)
let is_owner_card = server_state.get("is_owner_card")
.and_then(|v| v.as_bool())
.unwrap_or(false);
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());
// If this is the owner's card (not received from someone), show a message
if is_owner_card {
return view! {
<div class="text-center py-4">
<p class="text-gray-400">
"This is your business card. Drop it in a scene for others to pick up."
</p>
<p class="text-gray-500 text-sm mt-2">
"Recipients will see your profile information."
</p>
</div>
}.into_any();
}
// Get snapshot from server_private_state (received cards)
let Some(snapshot) = server_private_state.get("snapshot").cloned() else {
// No snapshot means loose prop on scene - show pickup message
return view! {
<div class="text-center py-4">
<p class="text-gray-400">
"Pick up this business card to see the owner's profile information."
</p>
</div>
}.into_any();
};
// Extract profile data from snapshot
let name = snapshot.get("display_name").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| {
// Combine first and last name if display_name not set
let first = snapshot.get("name_first").and_then(|v| v.as_str());
let last = snapshot.get("name_last").and_then(|v| v.as_str());
match (first, last) {
(Some(f), Some(l)) => Some(format!("{} {}", f, l)),
(Some(f), None) => Some(f.to_string()),
(None, Some(l)) => Some(l.to_string()),
(None, None) => None,
}
});
let username = snapshot.get("username").and_then(|v| v.as_str()).map(|s| s.to_string());
let summary = snapshot.get("summary").and_then(|v| v.as_str()).map(|s| s.to_string());
let homepage = snapshot.get("homepage").and_then(|v| v.as_str()).map(|s| s.to_string());
let email = snapshot.get("email").and_then(|v| v.as_str()).map(|s| s.to_string());
let phone = snapshot.get("phone").and_then(|v| v.as_str()).map(|s| s.to_string());
view! {
<div class="space-y-4">
@ -479,85 +527,65 @@ pub fn BusinessCardView(
{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>
{summary.clone().map(|s| view! {
<p class="text-gray-400 text-sm mt-2 italic">"\""{ s }"\""</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>
}
})}
// Contact info
{(email.is_some() || phone.is_some() || homepage.is_some()).then(|| view! {
<div class="space-y-1 text-center text-sm">
{email.clone().map(|e| {
let mailto = format!("mailto:{}", e);
view! {
<p class="text-gray-300">
<span class="text-gray-500">"Email: "</span>
<a href=mailto class="text-blue-400 hover:underline">{e}</a>
</p>
}
})}
{phone.clone().map(|p| {
let tel = format!("tel:{}", p);
view! {
<p class="text-gray-300">
<span class="text-gray-500">"Phone: "</span>
<a href=tel class="text-blue-400 hover:underline">{p}</a>
</p>
}
})}
{homepage.clone().map(|url| {
let href = url.clone();
view! {
<p class="text-gray-300">
<span class="text-gray-500">"Website: "</span>
<a href=href target="_blank" rel="noopener noreferrer" class="text-blue-400 hover:underline">{url}</a>
</p>
}
})}
</div>
})}
{github.clone().map(|username| {
let url = format!("https://github.com/{}", username);
view! {
// View Profile button (if we have username from snapshot)
{username.clone().map(|u| {
let profile_url = format!("/users/{}", u);
view! {
<div class="flex justify-center pt-4 border-t border-gray-700">
<a
href=url
href=profile_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"
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-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"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
"Website"
"View Full Profile"
</a>
}
})}
</div>
</div>
}
})}
</div>
}
}.into_any()
}
/// External links view for props with link arrays.

View file

@ -114,6 +114,7 @@ pub fn RealmSceneViewer(
let (context_menu_open, set_context_menu_open) = signal(false);
let (context_menu_position, set_context_menu_position) = signal((0.0_f64, 0.0_f64));
let (context_menu_target, set_context_menu_target) = signal(Option::<String>::None);
let (context_menu_username, set_context_menu_username) = signal(Option::<String>::None);
// Prop context menu state
let (prop_context_menu_open, set_prop_context_menu_open) = signal(false);
@ -258,13 +259,13 @@ pub fn RealmSceneViewer(
if hit_test_canvas(&canvas, client_x, client_y) {
if let Ok(member_id) = member_id_str.parse::<Uuid>() {
if my_user_id != Some(member_id) {
if let Some(name) = members.get().iter()
if let Some(member) = members.get().iter()
.find(|m| m.member.user_id == member_id)
.map(|m| m.member.display_name.clone())
{
ev.prevent_default();
set_context_menu_position.set((client_x, client_y));
set_context_menu_target.set(Some(name));
set_context_menu_target.set(Some(member.member.display_name.clone()));
set_context_menu_username.set(Some(member.member.username.clone()));
set_context_menu_open.set(true);
return;
}
@ -581,22 +582,40 @@ pub fn RealmSceneViewer(
open=Signal::derive(move || context_menu_open.get())
position=Signal::derive(move || context_menu_position.get())
header=Signal::derive(move || context_menu_target.get())
items=Signal::derive(move || vec![ContextMenuItem { label: "Whisper".to_string(), action: "whisper".to_string() }])
items=Signal::derive(move || vec![
ContextMenuItem { label: "View Profile".to_string(), action: "view_profile".to_string() },
ContextMenuItem { label: "Whisper".to_string(), action: "whisper".to_string() },
])
on_select=Callback::new({
let on_whisper_request = on_whisper_request.clone();
move |action: String| {
if action == "whisper" {
if let Some(target) = context_menu_target.get() {
if let Some(ref callback) = on_whisper_request {
callback.run(target);
match action.as_str() {
"view_profile" => {
if let Some(username) = context_menu_username.get() {
#[cfg(feature = "hydrate")]
{
let url = format!("/users/{}", username);
let _ = web_sys::window()
.unwrap()
.open_with_url_and_target(&url, "_blank");
}
}
}
"whisper" => {
if let Some(target) = context_menu_target.get() {
if let Some(ref callback) = on_whisper_request {
callback.run(target);
}
}
}
_ => {}
}
}
})
on_close=Callback::new(move |_: ()| {
set_context_menu_open.set(false);
set_context_menu_target.set(None);
set_context_menu_username.set(None);
})
/>
<ContextMenu

View file

@ -118,6 +118,8 @@ pub struct ModCommandResultInfo {
pub struct MemberIdentityInfo {
/// User ID of the member.
pub user_id: uuid::Uuid,
/// New username (for profile URLs).
pub username: String,
/// New display name.
pub display_name: String,
/// Whether the member is still a guest.
@ -715,6 +717,7 @@ fn handle_server_message(
}
ServerMessage::MemberIdentityUpdated {
user_id,
username,
display_name,
is_guest,
} => {
@ -723,6 +726,7 @@ fn handle_server_message(
.iter_mut()
.find(|m| m.member.user_id == user_id)
{
member.member.username = username.clone();
member.member.display_name = display_name.clone();
member.member.is_guest = is_guest;
}
@ -730,6 +734,7 @@ fn handle_server_message(
state.members.clone(),
MemberIdentityInfo {
user_id,
username,
display_name,
is_guest,
},

View file

@ -5,11 +5,15 @@
pub mod home;
pub mod login;
pub mod password_reset;
pub mod profile;
pub mod realm;
pub mod signup;
pub mod user_profile;
pub use home::*;
pub use login::*;
pub use password_reset::*;
pub use profile::*;
pub use realm::*;
pub use signup::*;
pub use user_profile::*;

File diff suppressed because it is too large Load diff

View file

@ -470,13 +470,15 @@ pub fn RealmPage() -> impl IntoView {
.forget();
}
WsEvent::MemberIdentityUpdated(info) => {
// Update the member's display name in the members list
// Update the member's identity in the members list
set_members.update(|members| {
if let Some(member) = members
.iter_mut()
.find(|m| m.member.user_id == info.user_id)
{
member.member.username = info.username.clone();
member.member.display_name = info.display_name.clone();
member.member.is_guest = info.is_guest;
}
});
}
@ -735,6 +737,22 @@ pub fn RealmPage() -> impl IntoView {
#[cfg(not(feature = "hydrate"))]
let on_view_prop_state = Callback::new(move |_prop_id: Uuid| {});
// Handle inventory item state view request (View Info) via WebSocket
#[cfg(feature = "hydrate")]
let on_view_inventory_item_state = Callback::new(move |item_id: Uuid| {
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::ViewPropState {
prop_id: item_id,
is_loose_prop: false, // inventory item, not loose prop
});
}
});
});
#[cfg(not(feature = "hydrate"))]
let on_view_inventory_item_state = Callback::new(move |_item_id: Uuid| {});
// Handle global keyboard shortcuts (e+0-9 for emotions, : for chat focus)
#[cfg(feature = "hydrate")]
{
@ -1208,6 +1226,16 @@ pub fn RealmPage() -> impl IntoView {
let on_open_register_cb = Callback::new(move |_: ()| {
set_register_modal_open.set(true);
});
#[cfg(feature = "hydrate")]
let navigate_for_profile = use_navigate();
let slug_for_profile = slug.clone();
let on_open_profile_cb = Callback::new(move |_: ()| {
#[cfg(feature = "hydrate")]
{
let profile_url = format!("/profile?realm={}", slug_for_profile.get());
let _ = navigate_for_profile(&profile_url, Default::default());
}
});
let on_whisper_request_cb = Callback::new(move |target: String| {
whisper_target.set(Some(target));
});
@ -1326,6 +1354,7 @@ pub fn RealmPage() -> impl IntoView {
on_mod_command=on_mod_command_cb
is_guest=Signal::derive(move || is_guest.get())
on_open_register=on_open_register_cb
on_open_profile=on_open_profile_cb
/>
</div>
</div>
@ -1365,6 +1394,7 @@ pub fn RealmPage() -> impl IntoView {
ws_sender=ws_sender_for_inv
realm_slug=Signal::derive(move || slug.get())
is_guest=Signal::derive(move || is_guest.get())
on_view_item_state=on_view_inventory_item_state
/>
}
}

View file

@ -0,0 +1,339 @@
//! Public user profile view page.
//!
//! Displays a user's public profile at /users/{username}.
//! Respects visibility settings - hidden content is not shown.
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use leptos_router::hooks::use_params_map;
use crate::components::{Card, ErrorAlert, PageLayout};
use chattyness_db::models::{PublicProfile, UserContact, UserOrganization};
/// Public user profile view page.
#[component]
pub fn UserProfilePage() -> impl IntoView {
let params = use_params_map();
let username = move || params.read().get("username").unwrap_or_default();
let (profile, set_profile) = signal(Option::<PublicProfile>::None);
let (loading, set_loading) = signal(true);
let (error, set_error) = signal(Option::<String>::None);
// Fetch profile on mount
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
Effect::new(move |_| {
let username = username();
if username.is_empty() {
return;
}
spawn_local(async move {
let resp = Request::get(&format!("/api/users/{}", username))
.send()
.await;
set_loading.set(false);
match resp {
Ok(r) if r.ok() => {
if let Ok(p) = r.json::<PublicProfile>().await {
set_profile.set(Some(p));
} else {
set_error.set(Some("Failed to parse profile".to_string()));
}
}
Ok(r) if r.status() == 404 => {
set_error.set(Some("User not found".to_string()));
}
Ok(_) => {
set_error.set(Some("Failed to load profile".to_string()));
}
Err(_) => {
set_error.set(Some("Network error".to_string()));
}
}
});
});
}
view! {
<PageLayout>
<div class="max-w-4xl mx-auto px-4 py-8">
<Show
when=move || loading.get()
fallback=move || {
view! {
<Show
when=move || error.get().is_some()
fallback=move || {
view! {
{move || {
profile
.get()
.map(|p| {
view! { <ProfileView profile=p /> }
})
}}
}
}
>
<Card>
<ErrorAlert message=Signal::derive(move || error.get()) />
</Card>
</Show>
}
}
>
<Card>
<div class="animate-pulse space-y-4">
<div class="h-8 bg-gray-700 rounded w-1/3"></div>
<div class="h-4 bg-gray-700 rounded w-2/3"></div>
<div class="h-4 bg-gray-700 rounded w-1/2"></div>
</div>
</Card>
</Show>
</div>
</PageLayout>
}
}
/// Profile view component.
#[component]
fn ProfileView(profile: PublicProfile) -> impl IntoView {
let display_name = profile.display_name.clone();
let username = profile.username.clone();
let email = profile.email.clone();
let phone = profile.phone.clone();
let summary = profile.summary.clone();
let bio = profile.bio.clone();
let homepage = profile.homepage.clone();
let member_since = profile.member_since.format("%B %Y").to_string();
let is_owner = profile.is_owner;
let contacts = profile.contacts.clone();
let organizations = profile.organizations.clone();
view! {
<div class="space-y-6">
// Header section
<Card>
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
// Avatar placeholder
<div class="w-20 h-20 rounded-full bg-gray-600 flex items-center justify-center text-3xl font-bold text-gray-300">
{display_name.chars().next().unwrap_or('?').to_uppercase().to_string()}
</div>
<div>
<h1 class="text-2xl font-bold text-white">{display_name.clone()}</h1>
<p class="text-gray-400">"@"{username.clone()}</p>
{summary
.as_ref()
.map(|s| {
let s = s.clone();
view! { <p class="text-gray-300 mt-1">{s}</p> }
})}
</div>
</div>
{if is_owner {
Some(
view! {
<a
href="/profile"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
"Edit Profile"
</a>
},
)
} else {
None
}}
</div>
<div class="mt-4 pt-4 border-t border-gray-700 flex flex-wrap gap-4 text-sm text-gray-400">
<span>"Member since " {member_since}</span>
{email
.as_ref()
.map(|e| {
let href = format!("mailto:{}", e);
let display = e.clone();
view! {
<a
href=href
class="text-blue-400 hover:text-blue-300"
>
{display}
</a>
}
})}
{phone
.as_ref()
.map(|p| {
let href = format!("tel:{}", p);
let display = p.clone();
view! {
<a
href=href
class="text-blue-400 hover:text-blue-300"
>
{display}
</a>
}
})}
{homepage
.as_ref()
.map(|url| {
let url = url.clone();
let display_url = url
.replace("https://", "")
.replace("http://", "")
.trim_end_matches('/')
.to_string();
view! {
<a
href=url
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-300"
>
{display_url}
</a>
}
})}
</div>
</Card>
// Bio section
{bio
.as_ref()
.map(|b| {
let b = b.clone();
view! {
<Card>
<h2 class="text-lg font-semibold text-white mb-3">"About"</h2>
<p class="text-gray-300 whitespace-pre-wrap">{b}</p>
</Card>
}
})}
// Contacts section
{contacts
.as_ref()
.filter(|c| !c.is_empty())
.map(|c| {
let contacts = c.clone();
view! { <ContactsView contacts=contacts /> }
})}
// Organizations section
{organizations
.as_ref()
.filter(|o| !o.is_empty())
.map(|o| {
let orgs = o.clone();
view! { <OrganizationsView organizations=orgs /> }
})}
</div>
}
}
/// Contacts display component.
#[component]
fn ContactsView(contacts: Vec<UserContact>) -> impl IntoView {
view! {
<Card>
<h2 class="text-lg font-semibold text-white mb-3">"Contacts"</h2>
<ul class="space-y-2">
{contacts
.into_iter()
.map(|contact| {
let platform_label = contact.platform.label();
let value = contact.value.clone();
let label = contact.label.clone();
view! {
<li class="flex items-center gap-2">
<span class="text-blue-400 font-medium">{platform_label}</span>
<span class="text-gray-300">{value}</span>
{label
.map(|l| {
view! {
<span class="text-gray-500">"(" {l} ")"</span>
}
})}
</li>
}
})
.collect::<Vec<_>>()}
</ul>
</Card>
}
}
/// Organizations display component.
#[component]
fn OrganizationsView(organizations: Vec<UserOrganization>) -> impl IntoView {
view! {
<Card>
<h2 class="text-lg font-semibold text-white mb-3">"Organizations"</h2>
<ul class="space-y-3">
{organizations
.into_iter()
.map(|org| {
let name = org.name.clone();
let role = org.role.clone();
let department = org.department.clone();
let is_current = org.is_current;
let dates = format_org_dates(&org);
view! {
<li class="border-l-2 border-gray-600 pl-3">
<div class="font-medium text-white">
{name}
{if is_current {
Some(
view! {
<span class="ml-2 px-2 py-0.5 text-xs bg-green-900 text-green-300 rounded">
"Current"
</span>
},
)
} else {
None
}}
</div>
{role
.map(|r| {
view! { <div class="text-gray-300">{r}</div> }
})}
{department
.map(|d| {
view! { <div class="text-gray-400 text-sm">{d}</div> }
})}
{dates
.map(|d| {
view! { <div class="text-gray-500 text-sm">{d}</div> }
})}
</li>
}
})
.collect::<Vec<_>>()}
</ul>
</Card>
}
}
/// Format organization dates for display.
fn format_org_dates(org: &UserOrganization) -> Option<String> {
match (org.start_date, org.end_date, org.is_current) {
(Some(start), None, true) => Some(format!("{} - Present", start.format("%b %Y"))),
(Some(start), Some(end), false) => {
Some(format!("{} - {}", start.format("%b %Y"), end.format("%b %Y")))
}
(Some(start), None, false) => Some(format!("{}", start.format("%b %Y"))),
(None, Some(end), _) => Some(format!("Until {}", end.format("%b %Y"))),
_ => None,
}
}

View file

@ -15,7 +15,7 @@ use leptos_router::{
components::{Route, Routes},
};
use crate::pages::{HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage};
use crate::pages::{HomePage, LoginPage, PasswordResetPage, ProfilePage, RealmPage, SignupPage, UserProfilePage};
/// User routes that can be embedded in a parent Router.
///
@ -29,7 +29,9 @@ pub fn UserRoutes() -> impl IntoView {
<Route path=StaticSegment("") view=LoginPage />
<Route path=StaticSegment("signup") view=SignupPage />
<Route path=StaticSegment("home") view=HomePage />
<Route path=StaticSegment("profile") view=ProfilePage />
<Route path=StaticSegment("password-reset") view=PasswordResetPage />
<Route path=(StaticSegment("users"), ParamSegment("username")) view=UserProfilePage />
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage />
<Route
path=(