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:
parent
cd8dfb94a3
commit
710985638f
35 changed files with 4932 additions and 435 deletions
|
|
@ -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;
|
||||
|
|
|
|||
286
crates/chattyness-user-ui/src/api/profile.rs
Normal file
286
crates/chattyness-user-ui/src/api/profile.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
1605
crates/chattyness-user-ui/src/pages/profile.rs
Normal file
1605
crates/chattyness-user-ui/src/pages/profile.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
339
crates/chattyness-user-ui/src/pages/user_profile.rs
Normal file
339
crates/chattyness-user-ui/src/pages/user_profile.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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=(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue