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

@ -58,6 +58,9 @@ serde_json = "1"
# Error handling
thiserror = "2"
# Enum utilities
strum = { version = "0.26", features = ["derive"] }
# CLI
clap = { version = "4", features = ["derive", "env"] }

View file

@ -11,7 +11,7 @@ use leptos_router::{
};
// Re-export user pages for inline route definitions
use chattyness_user_ui::pages::{HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage};
use chattyness_user_ui::pages::{HomePage, LoginPage, PasswordResetPage, ProfilePage, RealmPage, SignupPage, UserProfilePage};
// Lazy-load admin pages to split WASM bundle
// Each lazy function includes the admin CSS stylesheet for on-demand loading
@ -257,7 +257,9 @@ pub fn CombinedApp() -> 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=(

View file

@ -2,7 +2,7 @@
use axum::{Json, extract::State};
use chattyness_db::{
models::{ServerConfig, UpdateServerConfigRequest},
models::{ServerConfig, UpdateServerConfigRequest, UpdateServerDefaultAvatarsRequest},
queries::owner as queries,
};
use chattyness_error::AppError;
@ -23,3 +23,12 @@ pub async fn update_config(
let config = queries::update_server_config(&pool, &req).await?;
Ok(Json(config))
}
/// Update server default avatars.
pub async fn update_default_avatars(
State(pool): State<PgPool>,
Json(req): Json<UpdateServerDefaultAvatarsRequest>,
) -> Result<Json<()>, AppError> {
queries::update_server_default_avatars(&pool, &req).await?;
Ok(Json(()))
}

View file

@ -2,7 +2,7 @@
use axum::{
Router,
routing::{delete, get, post, put},
routing::{delete, get, patch, post, put},
};
use super::{auth, avatars, config, dashboard, loose_props, props, realms, scenes, spots, staff, users};
@ -26,6 +26,10 @@ pub fn admin_api_router() -> Router<AdminAppState> {
"/config",
get(config::get_config).put(config::update_config),
)
.route(
"/config/default-avatars",
patch(config::update_default_avatars),
)
// API - Staff
.route("/staff", get(staff::list_staff).post(staff::create_staff))
.route("/staff/{user_id}", delete(staff::delete_staff))

View file

@ -8,6 +8,7 @@ chattyness-error = { workspace = true, optional = true }
chattyness-shared = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
uuid.workspace = true
chrono.workspace = true

View file

@ -581,6 +581,182 @@ impl std::str::FromStr for AgeCategory {
}
}
/// Avatar source for profile pictures.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[derive(strum::Display, strum::EnumString)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "avatar_source", rename_all = "snake_case")
)]
#[serde(rename_all = "snake_case")]
pub enum AvatarSource {
#[default]
Local,
Discord,
Github,
GoogleScholar,
Libravatar,
Gravatar,
}
/// Profile visibility levels.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[derive(strum::Display, strum::EnumString)]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "profile_visibility", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum ProfileVisibility {
#[default]
Public,
Members,
Friends,
Private,
}
/// Contact/social platform identifiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(strum::Display, strum::EnumString)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "contact_platform", rename_all = "snake_case")
)]
#[serde(rename_all = "snake_case")]
pub enum ContactPlatform {
// Social
Discord,
Linkedin,
Facebook,
Twitter,
Instagram,
Threads,
Pinterest,
// Media
Youtube,
Spotify,
Substack,
Patreon,
Linktree,
// Development
Github,
Gitlab,
Gitea,
Codeberg,
Stackexchange,
CratesIo,
PauseCpan,
Npm,
Devpost,
Huggingface,
// Academic
GoogleScholar,
Wikidata,
WikimediaCommons,
Wikipedia,
// Other
Steam,
AmazonAuthor,
Openstreetmap,
Phone,
EmailAlt,
Website,
}
impl ContactPlatform {
/// Get the display label for this platform.
pub fn label(&self) -> &'static str {
match self {
ContactPlatform::Discord => "Discord",
ContactPlatform::Linkedin => "LinkedIn",
ContactPlatform::Facebook => "Facebook",
ContactPlatform::Twitter => "Twitter/X",
ContactPlatform::Instagram => "Instagram",
ContactPlatform::Threads => "Threads",
ContactPlatform::Pinterest => "Pinterest",
ContactPlatform::Youtube => "YouTube",
ContactPlatform::Spotify => "Spotify",
ContactPlatform::Substack => "Substack",
ContactPlatform::Patreon => "Patreon",
ContactPlatform::Linktree => "Linktree",
ContactPlatform::Github => "GitHub",
ContactPlatform::Gitlab => "GitLab",
ContactPlatform::Gitea => "Gitea",
ContactPlatform::Codeberg => "Codeberg",
ContactPlatform::Stackexchange => "Stack Exchange",
ContactPlatform::CratesIo => "crates.io",
ContactPlatform::PauseCpan => "PAUSE/CPAN",
ContactPlatform::Npm => "npm",
ContactPlatform::Devpost => "Devpost",
ContactPlatform::Huggingface => "Hugging Face",
ContactPlatform::GoogleScholar => "Google Scholar",
ContactPlatform::Wikidata => "Wikidata",
ContactPlatform::WikimediaCommons => "Wikimedia Commons",
ContactPlatform::Wikipedia => "Wikipedia",
ContactPlatform::Steam => "Steam",
ContactPlatform::AmazonAuthor => "Amazon Author",
ContactPlatform::Openstreetmap => "OpenStreetMap",
ContactPlatform::Phone => "Phone",
ContactPlatform::EmailAlt => "Email",
ContactPlatform::Website => "Website",
}
}
/// Get all platforms grouped by category.
pub fn grouped() -> Vec<(&'static str, Vec<ContactPlatform>)> {
vec![
("Social", vec![
ContactPlatform::Discord,
ContactPlatform::Linkedin,
ContactPlatform::Facebook,
ContactPlatform::Twitter,
ContactPlatform::Instagram,
ContactPlatform::Threads,
ContactPlatform::Pinterest,
]),
("Media", vec![
ContactPlatform::Youtube,
ContactPlatform::Spotify,
ContactPlatform::Substack,
ContactPlatform::Patreon,
ContactPlatform::Linktree,
]),
("Development", vec![
ContactPlatform::Github,
ContactPlatform::Gitlab,
ContactPlatform::Gitea,
ContactPlatform::Codeberg,
ContactPlatform::Stackexchange,
ContactPlatform::CratesIo,
ContactPlatform::PauseCpan,
ContactPlatform::Npm,
ContactPlatform::Devpost,
ContactPlatform::Huggingface,
]),
("Academic", vec![
ContactPlatform::GoogleScholar,
ContactPlatform::Wikidata,
ContactPlatform::WikimediaCommons,
ContactPlatform::Wikipedia,
]),
("Other", vec![
ContactPlatform::Steam,
ContactPlatform::AmazonAuthor,
ContactPlatform::Openstreetmap,
ContactPlatform::Phone,
ContactPlatform::EmailAlt,
ContactPlatform::Website,
]),
]
}
}
// =============================================================================
// User Models
// =============================================================================
@ -625,6 +801,256 @@ pub struct UserSummary {
pub avatar_url: Option<String>,
}
/// Full user profile for editing.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct UserProfile {
pub id: Uuid,
pub username: String,
pub email: Option<String>,
pub phone: Option<String>,
pub display_name: String,
pub name_first: Option<String>,
pub name_last: Option<String>,
pub summary: Option<String>,
pub homepage: Option<String>,
pub bio: Option<String>,
pub avatar_source: AvatarSource,
pub profile_visibility: ProfileVisibility,
pub contacts_visibility: ProfileVisibility,
pub organizations_visibility: ProfileVisibility,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Public profile view - used for displaying profile to others.
/// Fields are optional because visibility settings may hide them.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicProfile {
pub id: Uuid,
pub username: String,
pub email: Option<String>,
pub phone: Option<String>,
pub display_name: String,
pub summary: Option<String>,
pub homepage: Option<String>,
pub bio: Option<String>,
pub avatar_source: AvatarSource,
pub contacts: Option<Vec<UserContact>>,
pub organizations: Option<Vec<UserOrganization>>,
pub member_since: DateTime<Utc>,
/// Whether the viewer is the profile owner (can edit)
pub is_owner: bool,
}
/// A user's contact/social link.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct UserContact {
pub id: Uuid,
pub user_id: Uuid,
pub platform: ContactPlatform,
pub value: String,
pub label: Option<String>,
pub sort_order: i16,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A user's organization affiliation.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct UserOrganization {
pub id: Uuid,
pub user_id: Uuid,
pub wikidata_qid: Option<String>,
pub name: String,
pub role: Option<String>,
pub department: Option<String>,
pub start_date: Option<NaiveDate>,
pub end_date: Option<NaiveDate>,
pub is_current: bool,
pub sort_order: i16,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Request to update basic profile fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateProfileRequest {
pub display_name: String,
pub name_first: Option<String>,
pub name_last: Option<String>,
pub summary: Option<String>,
pub homepage: Option<String>,
pub bio: Option<String>,
pub phone: Option<String>,
}
#[cfg(feature = "ssr")]
impl UpdateProfileRequest {
/// Validate the update profile request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.display_name, "Display name")?;
validation::validate_length(&self.display_name, "Display name", 1, 50)?;
if let Some(ref summary) = self.summary {
validation::validate_length(summary, "Summary", 0, 200)?;
}
if let Some(ref homepage) = self.homepage {
if !homepage.is_empty() {
validation::validate_length(homepage, "Homepage URL", 0, 500)?;
// Basic URL validation: must start with http:// or https://
if !homepage.starts_with("http://") && !homepage.starts_with("https://") {
return Err(AppError::Validation("Homepage must be a valid URL starting with http:// or https://".to_string()));
}
}
}
if let Some(ref bio) = self.bio {
validation::validate_length(bio, "Bio", 0, 2000)?;
}
Ok(())
}
}
/// Request to update visibility settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateVisibilityRequest {
pub profile_visibility: ProfileVisibility,
pub contacts_visibility: ProfileVisibility,
pub organizations_visibility: ProfileVisibility,
pub avatar_source: AvatarSource,
}
/// Request to create a new contact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateContactRequest {
pub platform: ContactPlatform,
pub value: String,
pub label: Option<String>,
}
/// Request to update an existing contact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateContactRequest {
pub platform: ContactPlatform,
pub value: String,
pub label: Option<String>,
pub sort_order: i16,
}
/// Validate contact fields (shared between create and update).
#[cfg(feature = "ssr")]
fn validate_contact_fields(value: &str, label: Option<&str>) -> Result<(), AppError> {
validation::validate_non_empty(value, "Contact value")?;
validation::validate_length(value, "Contact value", 1, 500)?;
if let Some(l) = label {
validation::validate_length(l, "Label", 0, 100)?;
}
Ok(())
}
#[cfg(feature = "ssr")]
impl CreateContactRequest {
/// Validate the create contact request.
pub fn validate(&self) -> Result<(), AppError> {
validate_contact_fields(&self.value, self.label.as_deref())
}
}
#[cfg(feature = "ssr")]
impl UpdateContactRequest {
/// Validate the update contact request.
pub fn validate(&self) -> Result<(), AppError> {
validate_contact_fields(&self.value, self.label.as_deref())
}
}
/// Request to create a new organization affiliation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateOrganizationRequest {
pub wikidata_qid: Option<String>,
pub name: String,
pub role: Option<String>,
pub department: Option<String>,
pub start_date: Option<NaiveDate>,
pub end_date: Option<NaiveDate>,
pub is_current: bool,
}
/// Request to update an existing organization affiliation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateOrganizationRequest {
pub wikidata_qid: Option<String>,
pub name: String,
pub role: Option<String>,
pub department: Option<String>,
pub start_date: Option<NaiveDate>,
pub end_date: Option<NaiveDate>,
pub is_current: bool,
pub sort_order: i16,
}
/// Validate organization fields (shared between create and update).
#[cfg(feature = "ssr")]
fn validate_organization_fields(
name: &str,
role: Option<&str>,
department: Option<&str>,
wikidata_qid: Option<&str>,
start_date: Option<NaiveDate>,
end_date: Option<NaiveDate>,
) -> Result<(), AppError> {
validation::validate_non_empty(name, "Organization name")?;
validation::validate_length(name, "Organization name", 1, 200)?;
if let Some(r) = role {
validation::validate_length(r, "Role", 0, 100)?;
}
if let Some(d) = department {
validation::validate_length(d, "Department", 0, 100)?;
}
if let Some(qid) = wikidata_qid {
if !qid.is_empty() && !qid.starts_with('Q') {
return Err(AppError::Validation("Wikidata QID must start with 'Q'".to_string()));
}
}
if let (Some(start), Some(end)) = (start_date, end_date) {
if start > end {
return Err(AppError::Validation("Start date must be before end date".to_string()));
}
}
Ok(())
}
#[cfg(feature = "ssr")]
impl CreateOrganizationRequest {
/// Validate the create organization request.
pub fn validate(&self) -> Result<(), AppError> {
validate_organization_fields(
&self.name,
self.role.as_deref(),
self.department.as_deref(),
self.wikidata_qid.as_deref(),
self.start_date,
self.end_date,
)
}
}
#[cfg(feature = "ssr")]
impl UpdateOrganizationRequest {
/// Validate the update organization request.
pub fn validate(&self) -> Result<(), AppError> {
validate_organization_fields(
&self.name,
self.role.as_deref(),
self.department.as_deref(),
self.wikidata_qid.as_deref(),
self.start_date,
self.end_date,
)
}
}
// =============================================================================
// Realm Models
// =============================================================================
@ -955,16 +1381,40 @@ pub struct PrivateStateBundle {
impl PropStateView {
/// Detect action hint from state content.
pub fn detect_action_hint(server_state: &serde_json::Value) -> Option<PropActionHint> {
use serde_json::Value;
Self::detect_action_hint_full(server_state, None, None)
}
// Check for business card pattern (profile with social links)
if let Some(profile) = server_state.get("profile") {
let profile: &Value = profile;
if profile.get("linkedin").is_some()
|| profile.get("github").is_some()
|| profile.get("twitter").is_some()
|| profile.get("website").is_some()
{
/// Detect action hint from state content, including private state.
pub fn detect_action_hint_with_private(
server_state: &serde_json::Value,
server_private_state: Option<&serde_json::Value>,
) -> Option<PropActionHint> {
Self::detect_action_hint_full(server_state, server_private_state, None)
}
/// Detect action hint from state content, private state, and prop name.
pub fn detect_action_hint_full(
server_state: &serde_json::Value,
server_private_state: Option<&serde_json::Value>,
prop_name: Option<&str>,
) -> Option<PropActionHint> {
// Check for business card flag (is_owner_card: true)
if let Some(is_owner) = server_state.get("is_owner_card") {
if is_owner.as_bool() == Some(true) {
return Some(PropActionHint::BusinessCard);
}
}
// Check for business card snapshot in private state (received card)
if let Some(private) = server_private_state {
if private.get("snapshot").is_some() {
return Some(PropActionHint::BusinessCard);
}
}
// Check for business card name pattern (dropped cards: "Name's Business Card")
if let Some(name) = prop_name {
if name.ends_with("'s Business Card") {
return Some(PropActionHint::BusinessCard);
}
}
@ -985,6 +1435,24 @@ impl PropStateView {
}
}
/// Snapshot of a user's profile captured when a business card is transferred.
///
/// This captures the giver's profile at the moment they dropped the card,
/// so the recipient can see the giver's information even if it changes later.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BusinessCardSnapshot {
pub user_id: Uuid,
pub username: String,
pub display_name: String,
pub name_first: Option<String>,
pub name_last: Option<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub summary: Option<String>,
pub homepage: Option<String>,
pub captured_at: DateTime<Utc>,
}
/// An inventory item (user-owned prop).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
@ -998,6 +1466,9 @@ pub struct InventoryItem {
pub is_transferable: bool,
pub is_portable: bool,
pub is_droppable: bool,
/// Whether this prop is unique (only one can exist in the world).
/// Unique props cannot be copied via CopyAndDropProp.
pub is_unique: bool,
/// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload)
pub origin: PropOrigin,
pub acquired_at: DateTime<Utc>,
@ -2588,6 +3059,17 @@ impl UpdateServerConfigRequest {
}
}
/// Request to update server default avatars.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateServerDefaultAvatarsRequest {
pub default_avatar_neutral_child: Option<Uuid>,
pub default_avatar_neutral_adult: Option<Uuid>,
pub default_avatar_male_child: Option<Uuid>,
pub default_avatar_male_adult: Option<Uuid>,
pub default_avatar_female_child: Option<Uuid>,
pub default_avatar_female_adult: Option<Uuid>,
}
// =============================================================================
// Staff Models
// =============================================================================
@ -3362,6 +3844,8 @@ pub struct ChannelMemberInfo {
pub user_id: Uuid,
/// Display name (user's display_name)
pub display_name: String,
/// Username for profile URL
pub username: String,
/// X coordinate in scene space
pub position_x: f64,
/// Y coordinate in scene space

View file

@ -169,6 +169,7 @@ pub async fn get_channel_members<'e>(
cm.instance_id as channel_id,
cm.user_id,
COALESCE(u.display_name, 'Anonymous') as display_name,
COALESCE(u.username, 'anonymous') as username,
ST_X(cm.position) as position_x,
ST_Y(cm.position) as position_y,
cm.facing_direction,
@ -206,6 +207,7 @@ pub async fn get_channel_member<'e>(
cm.instance_id as channel_id,
cm.user_id,
COALESCE(u.display_name, 'Anonymous') as display_name,
COALESCE(u.username, 'anonymous') as username,
ST_X(cm.position) as position_x,
ST_Y(cm.position) as position_y,
cm.facing_direction,

View file

@ -14,25 +14,28 @@ pub async fn list_user_inventory<'e>(
let items = sqlx::query_as::<_, InventoryItem>(
r#"
SELECT
id,
COALESCE(server_prop_id, realm_prop_id) as prop_id,
prop_name,
prop_asset_path,
layer,
is_transferable,
is_portable,
is_droppable,
origin,
acquired_at,
server_state,
realm_state,
user_state,
server_private_state,
realm_private_state,
user_private_state
FROM auth.inventory
WHERE user_id = $1
ORDER BY acquired_at DESC
inv.id,
COALESCE(inv.server_prop_id, inv.realm_prop_id) as prop_id,
inv.prop_name,
inv.prop_asset_path,
inv.layer,
inv.is_transferable,
inv.is_portable,
inv.is_droppable,
COALESCE(sp.is_unique, rp.is_unique, false) as is_unique,
inv.origin,
inv.acquired_at,
inv.server_state,
inv.realm_state,
inv.user_state,
inv.server_private_state,
inv.realm_private_state,
inv.user_private_state
FROM auth.inventory inv
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id
WHERE inv.user_id = $1
ORDER BY inv.acquired_at DESC
"#,
)
.bind(user_id)
@ -205,6 +208,7 @@ pub async fn list_realm_props<'e>(
/// - For unique props: checks no one owns it yet
/// - For non-unique props: checks user doesn't already own it
/// - Inserts into `auth.inventory` with `origin = server_library`
/// - For business cards: renames to "My Business Card" and sets `is_owner_card` flag
///
/// Returns the created inventory item or an appropriate error.
pub async fn acquire_server_prop<'e>(
@ -213,6 +217,7 @@ pub async fn acquire_server_prop<'e>(
user_id: Uuid,
) -> Result<InventoryItem, AppError> {
// Use a CTE to atomically check conditions and insert
// Business cards are renamed to "My Business Card" and marked with is_owner_card flag
let result: Option<InventoryItem> = sqlx::query_as(
r#"
WITH prop_check AS (
@ -228,7 +233,8 @@ pub async fn acquire_server_prop<'e>(
p.is_active,
p.is_public,
(p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok,
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok,
lower(p.name) LIKE '%businesscard%' AS is_business_card
FROM server.props p
WHERE p.id = $1
),
@ -268,14 +274,14 @@ pub async fn acquire_server_prop<'e>(
SELECT
$2,
oc.id,
oc.name,
CASE WHEN oc.is_business_card THEN 'My Business Card' ELSE oc.name END,
oc.asset_path,
oc.default_layer,
'server_library'::server.prop_origin,
oc.is_transferable,
oc.is_portable,
oc.is_droppable,
'{}'::jsonb,
CASE WHEN oc.is_business_card THEN '{"is_owner_card": true}'::jsonb ELSE '{}'::jsonb END,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
@ -306,7 +312,26 @@ pub async fn acquire_server_prop<'e>(
realm_private_state,
user_private_state
)
SELECT * FROM inserted
SELECT
ins.id,
ins.prop_id,
ins.prop_name,
ins.prop_asset_path,
ins.layer,
ins.is_transferable,
ins.is_portable,
ins.is_droppable,
oc.is_unique,
ins.origin,
ins.acquired_at,
ins.server_state,
ins.realm_state,
ins.user_state,
ins.server_private_state,
ins.realm_private_state,
ins.user_private_state
FROM inserted ins
CROSS JOIN ownership_check oc
"#,
)
.bind(prop_id)
@ -400,6 +425,7 @@ pub async fn get_server_prop_acquisition_error<'e>(
/// - For unique props: checks no one owns it yet
/// - For non-unique props: checks user doesn't already own it
/// - Inserts into `auth.inventory` with `origin = realm_library`
/// - For business cards: renames to "My Business Card" and sets `is_owner_card` flag
///
/// Returns the created inventory item or an appropriate error.
pub async fn acquire_realm_prop<'e>(
@ -409,6 +435,7 @@ pub async fn acquire_realm_prop<'e>(
user_id: Uuid,
) -> Result<InventoryItem, AppError> {
// Use a CTE to atomically check conditions and insert
// Business cards are renamed to "My Business Card" and marked with is_owner_card flag
let result: Option<InventoryItem> = sqlx::query_as(
r#"
WITH prop_check AS (
@ -423,7 +450,8 @@ pub async fn acquire_realm_prop<'e>(
p.is_active,
p.is_public,
(p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok,
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok,
lower(p.name) LIKE '%businesscard%' AS is_business_card
FROM realm.props p
WHERE p.id = $1 AND p.realm_id = $2
),
@ -463,14 +491,14 @@ pub async fn acquire_realm_prop<'e>(
SELECT
$3,
oc.id,
oc.name,
CASE WHEN oc.is_business_card THEN 'My Business Card' ELSE oc.name END,
oc.asset_path,
oc.default_layer,
'realm_library'::server.prop_origin,
oc.is_transferable,
true, -- realm props are portable by default
oc.is_droppable,
'{}'::jsonb,
CASE WHEN oc.is_business_card THEN '{"is_owner_card": true}'::jsonb ELSE '{}'::jsonb END,
'{}'::jsonb,
'{}'::jsonb,
'{}'::jsonb,
@ -501,7 +529,26 @@ pub async fn acquire_realm_prop<'e>(
realm_private_state,
user_private_state
)
SELECT * FROM inserted
SELECT
ins.id,
ins.prop_id,
ins.prop_name,
ins.prop_asset_path,
ins.layer,
ins.is_transferable,
ins.is_portable,
ins.is_droppable,
oc.is_unique,
ins.origin,
ins.acquired_at,
ins.server_state,
ins.realm_state,
ins.user_state,
ins.server_private_state,
ins.realm_private_state,
ins.user_private_state
FROM inserted ins
CROSS JOIN ownership_check oc
"#,
)
.bind(prop_id)
@ -662,24 +709,27 @@ pub async fn get_inventory_item<'e>(
let item = sqlx::query_as::<_, InventoryItem>(
r#"
SELECT
id,
COALESCE(server_prop_id, realm_prop_id) as prop_id,
prop_name,
prop_asset_path,
layer,
is_transferable,
is_portable,
is_droppable,
origin,
acquired_at,
server_state,
realm_state,
user_state,
server_private_state,
realm_private_state,
user_private_state
FROM auth.inventory
WHERE id = $1 AND user_id = $2
inv.id,
COALESCE(inv.server_prop_id, inv.realm_prop_id) as prop_id,
inv.prop_name,
inv.prop_asset_path,
inv.layer,
inv.is_transferable,
inv.is_portable,
inv.is_droppable,
COALESCE(sp.is_unique, rp.is_unique, false) as is_unique,
inv.origin,
inv.acquired_at,
inv.server_state,
inv.realm_state,
inv.user_state,
inv.server_private_state,
inv.realm_private_state,
inv.user_private_state
FROM auth.inventory inv
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id
WHERE inv.id = $1 AND inv.user_id = $2
"#,
)
.bind(item_id)

View file

@ -50,7 +50,7 @@ pub async fn list_channel_loose_props<'e>(
lp.dropped_by,
lp.expires_at,
lp.created_at,
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
lp.is_locked,
lp.locked_by,
@ -102,7 +102,7 @@ struct DropPropResult {
/// Returns the created loose prop.
/// Returns an error if the prop is non-droppable (essential prop).
/// Note: Public state (server_state, realm_state, user_state) is transferred to the loose prop.
/// Private state is NOT transferred (cleared when dropped).
/// For business cards: captures dropper's profile snapshot at drop time.
pub async fn drop_prop_to_canvas<'e>(
executor: impl PgExecutor<'e>,
inventory_item_id: Uuid,
@ -115,9 +115,24 @@ pub async fn drop_prop_to_canvas<'e>(
// Returns status flags plus the LooseProp data (if successful).
// Includes scale inherited from the source prop's default_scale.
// Transfers public state columns (server_state, realm_state, user_state).
// For business cards: builds snapshot of dropper's profile at drop time.
let result: Option<DropPropResult> = sqlx::query_as(
r#"
WITH item_info AS (
WITH dropper_info AS (
-- Get full profile for snapshot (business cards)
SELECT
id,
username,
display_name,
name_first,
name_last,
email,
phone,
summary,
homepage
FROM auth.users WHERE id = $2
),
item_info AS (
SELECT
inv.id,
inv.is_droppable,
@ -128,7 +143,8 @@ pub async fn drop_prop_to_canvas<'e>(
inv.server_state,
inv.realm_state,
inv.user_state,
COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale
COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale,
(inv.server_state->>'is_owner_card')::boolean = true AS is_business_card
FROM auth.inventory inv
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id
@ -151,7 +167,9 @@ pub async fn drop_prop_to_canvas<'e>(
expires_at,
server_state,
realm_state,
user_state
user_state,
server_private_state,
prop_name
)
SELECT
$3,
@ -161,10 +179,37 @@ pub async fn drop_prop_to_canvas<'e>(
(SELECT default_scale FROM item_info),
$2,
now() + interval '30 minutes',
di.server_state,
-- Clear is_owner_card flag for business cards when dropped
CASE WHEN (SELECT is_business_card FROM item_info)
THEN di.server_state - 'is_owner_card'
ELSE di.server_state
END,
di.realm_state,
di.user_state
di.user_state,
-- Build snapshot for business cards at drop time
CASE WHEN (SELECT is_business_card FROM item_info) THEN
jsonb_build_object(
'snapshot', jsonb_build_object(
'user_id', dr.id,
'username', dr.username,
'display_name', dr.display_name,
'name_first', dr.name_first,
'name_last', dr.name_last,
'email', dr.email,
'phone', dr.phone,
'summary', dr.summary,
'homepage', dr.homepage,
'captured_at', now()
)
)
ELSE '{}'::jsonb END,
-- Rename business cards to "{dropper}'s Business Card"
CASE WHEN (SELECT is_business_card FROM item_info)
THEN dr.display_name || '''s Business Card'
ELSE di.prop_name
END
FROM deleted_item di
CROSS JOIN dropper_info dr
RETURNING
id,
instance_id as channel_id,
@ -178,7 +223,8 @@ pub async fn drop_prop_to_canvas<'e>(
created_at,
server_state,
realm_state,
user_state
user_state,
prop_name
)
SELECT
EXISTS(SELECT 1 FROM item_info) AS item_existed,
@ -194,7 +240,7 @@ pub async fn drop_prop_to_canvas<'e>(
ip.dropped_by,
ip.expires_at,
ip.created_at,
di.prop_name,
ip.prop_name,
di.prop_asset_path,
ip.server_state,
ip.realm_state,
@ -297,44 +343,364 @@ pub async fn drop_prop_to_canvas<'e>(
}
}
/// Result row type for copy_and_drop_prop query.
#[derive(Debug, sqlx::FromRow)]
struct CopyDropPropResult {
item_existed: bool,
was_droppable: bool,
was_unique: bool,
id: Option<Uuid>,
channel_id: Option<Uuid>,
server_prop_id: Option<Uuid>,
realm_prop_id: Option<Uuid>,
position_x: Option<f32>,
position_y: Option<f32>,
scale: Option<f32>,
dropped_by: Option<Uuid>,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
created_at: Option<chrono::DateTime<chrono::Utc>>,
prop_name: Option<String>,
prop_asset_path: Option<String>,
server_state: Option<serde_json::Value>,
realm_state: Option<serde_json::Value>,
user_state: Option<serde_json::Value>,
}
/// Copy a prop from inventory and drop it to the canvas (keeping original in inventory).
///
/// Only works for props where `is_unique == false` and `is_droppable == true`.
/// Creates a new loose prop with 30-minute expiry without removing from inventory.
/// Note: Public state (server_state, realm_state, user_state) is copied to the loose prop.
/// For business cards: captures dropper's profile snapshot at drop time.
pub async fn copy_and_drop_prop<'e>(
executor: impl PgExecutor<'e>,
inventory_item_id: Uuid,
user_id: Uuid,
channel_id: Uuid,
position_x: f64,
position_y: f64,
) -> Result<LooseProp, AppError> {
// Single CTE that checks existence/droppability/uniqueness and performs the operation atomically.
// Returns status flags plus the LooseProp data (if successful).
// Includes scale inherited from the source prop's default_scale.
// Copies public state columns (server_state, realm_state, user_state).
// For business cards: builds snapshot of dropper's profile at copy time.
let result: Option<CopyDropPropResult> = sqlx::query_as(
r#"
WITH dropper_info AS (
-- Get full profile for snapshot (business cards)
SELECT
id,
username,
display_name,
name_first,
name_last,
email,
phone,
summary,
homepage
FROM auth.users WHERE id = $2
),
item_info AS (
SELECT
inv.id,
inv.is_droppable,
inv.server_prop_id,
inv.realm_prop_id,
inv.prop_name,
inv.prop_asset_path,
inv.server_state,
inv.realm_state,
inv.user_state,
COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale,
COALESCE(sp.is_unique, rp.is_unique, false) as is_unique,
(inv.server_state->>'is_owner_card')::boolean = true AS is_business_card
FROM auth.inventory inv
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id
WHERE inv.id = $1 AND inv.user_id = $2
),
inserted_prop AS (
INSERT INTO scene.loose_props (
instance_id,
server_prop_id,
realm_prop_id,
position,
scale,
dropped_by,
expires_at,
server_state,
realm_state,
user_state,
server_private_state,
prop_name
)
SELECT
$3,
ii.server_prop_id,
ii.realm_prop_id,
public.make_virtual_point($4::real, $5::real),
ii.default_scale,
$2,
now() + interval '30 minutes',
-- Clear is_owner_card flag for business cards when dropped
CASE WHEN ii.is_business_card
THEN ii.server_state - 'is_owner_card'
ELSE ii.server_state
END,
ii.realm_state,
ii.user_state,
-- Build snapshot for business cards at copy time
CASE WHEN ii.is_business_card THEN
jsonb_build_object(
'snapshot', jsonb_build_object(
'user_id', dr.id,
'username', dr.username,
'display_name', dr.display_name,
'name_first', dr.name_first,
'name_last', dr.name_last,
'email', dr.email,
'phone', dr.phone,
'summary', dr.summary,
'homepage', dr.homepage,
'captured_at', now()
)
)
ELSE '{}'::jsonb END,
-- Rename business cards to "{dropper}'s Business Card"
CASE WHEN ii.is_business_card
THEN dr.display_name || '''s Business Card'
ELSE ii.prop_name
END
FROM item_info ii
CROSS JOIN dropper_info dr
WHERE ii.is_droppable = true AND ii.is_unique = false
RETURNING
id,
instance_id as channel_id,
server_prop_id,
realm_prop_id,
ST_X(position)::real as position_x,
ST_Y(position)::real as position_y,
scale,
dropped_by,
expires_at,
created_at,
server_state,
realm_state,
user_state,
prop_name
)
SELECT
EXISTS(SELECT 1 FROM item_info) AS item_existed,
COALESCE((SELECT is_droppable FROM item_info), false) AS was_droppable,
COALESCE((SELECT is_unique FROM item_info), true) AS was_unique,
ip.id,
ip.channel_id,
ip.server_prop_id,
ip.realm_prop_id,
ip.position_x,
ip.position_y,
ip.scale,
ip.dropped_by,
ip.expires_at,
ip.created_at,
ip.prop_name,
ii.prop_asset_path,
ip.server_state,
ip.realm_state,
ip.user_state
FROM (SELECT 1) AS dummy
LEFT JOIN inserted_prop ip ON true
LEFT JOIN item_info ii ON true
"#,
)
.bind(inventory_item_id)
.bind(user_id)
.bind(channel_id)
.bind(position_x as f32)
.bind(position_y as f32)
.fetch_optional(executor)
.await?;
match result {
None => {
// Query returned no rows (shouldn't happen with our dummy table)
Err(AppError::Internal(
"Unexpected error copying prop to canvas".to_string(),
))
}
Some(r) if !r.item_existed => {
// Item didn't exist
Err(AppError::NotFound(
"Inventory item not found or not owned by user".to_string(),
))
}
Some(r) if r.item_existed && !r.was_droppable => {
// Item existed but is not droppable
Err(AppError::Forbidden(
"This prop cannot be dropped - it is an essential prop".to_string(),
))
}
Some(r) if r.item_existed && r.was_unique => {
// Item is unique and cannot be copied
Err(AppError::Forbidden(
"This prop is unique and cannot be copied".to_string(),
))
}
Some(CopyDropPropResult {
item_existed: true,
was_droppable: true,
was_unique: false,
id: Some(id),
channel_id: Some(channel_id),
server_prop_id,
realm_prop_id,
position_x: Some(position_x),
position_y: Some(position_y),
scale: Some(scale),
dropped_by,
expires_at: Some(expires_at),
created_at: Some(created_at),
prop_name: Some(prop_name),
prop_asset_path: Some(prop_asset_path),
server_state: Some(server_state),
realm_state: Some(realm_state),
user_state: Some(user_state),
}) => {
// Construct PropSource from the nullable columns
let source = if let Some(sid) = server_prop_id {
PropSource::Server(sid)
} else if let Some(rid) = realm_prop_id {
PropSource::Realm(rid)
} else {
return Err(AppError::Internal(
"Copied prop has neither server_prop_id nor realm_prop_id".to_string(),
));
};
// Success! Convert f32 positions to f64.
Ok(LooseProp {
id,
channel_id,
source,
position_x: position_x.into(),
position_y: position_y.into(),
scale,
dropped_by,
expires_at: Some(expires_at),
created_at,
prop_name,
prop_asset_path,
is_locked: false,
locked_by: None,
server_state,
realm_state,
user_state,
})
}
_ => {
// Some fields were unexpectedly null
Err(AppError::Internal(
"Unexpected null values in copy drop prop result".to_string(),
))
}
}
}
/// Pick up a loose prop (delete from loose_props, insert to inventory).
///
/// Returns the created inventory item.
/// Note: Public state (server_state, realm_state, user_state) is transferred from the loose prop.
/// Private state is initialized to empty (cleared for new owner).
/// Note: Public state is transferred from the loose prop.
/// Private state (snapshot) is transferred from loose prop (captured at drop time).
///
/// For business cards:
/// - Transfers snapshot that was captured when the card was dropped
/// - Updates existing card from same giver instead of creating duplicate
/// - Self-pickup: restores is_owner_card flag and clears snapshot
pub async fn pick_up_loose_prop<'e>(
executor: impl PgExecutor<'e>,
loose_prop_id: Uuid,
user_id: Uuid,
) -> Result<InventoryItem, AppError> {
// Use a CTE to delete from loose_props and insert to inventory
// Public state is transferred, private state is initialized to empty
// Simplified pickup query:
// - Snapshot was already captured at drop time, just transfer it
// - Handle self-pickup (picker == dropper): restore owner state
// - Handle deduplication for business cards from same giver
let item = sqlx::query_as::<_, InventoryItem>(
r#"
WITH deleted_prop AS (
DELETE FROM scene.loose_props
WHERE id = $1
AND (expires_at IS NULL OR expires_at > now())
RETURNING id, server_prop_id, realm_prop_id,
server_state, realm_state, user_state
RETURNING id, server_prop_id, realm_prop_id, prop_name,
server_state, realm_state, user_state,
server_private_state, dropped_by
),
source_info AS (
SELECT
COALESCE(sp.name, rp.name) as prop_name,
dp.*,
COALESCE(dp.prop_name, sp.name, rp.name) as final_prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
COALESCE(sp.default_layer, rp.default_layer) as layer,
COALESCE(sp.is_transferable, rp.is_transferable) as is_transferable,
COALESCE(sp.is_portable, true) as is_portable,
COALESCE(sp.is_droppable, rp.is_droppable, true) as is_droppable,
dp.server_prop_id,
dp.realm_prop_id,
dp.server_state,
dp.realm_state,
dp.user_state
COALESCE(sp.is_unique, rp.is_unique, false) as is_unique,
-- Detect business card by presence of snapshot
(dp.server_private_state->'snapshot') IS NOT NULL AS is_business_card,
-- Self-pickup: picker is the original dropper
dp.dropped_by = $2 AS is_self_pickup,
-- Get giver's user_id from snapshot for deduplication
(dp.server_private_state->'snapshot'->>'user_id')::uuid AS giver_id
FROM deleted_prop dp
LEFT JOIN server.props sp ON dp.server_prop_id = sp.id
LEFT JOIN realm.props rp ON dp.realm_prop_id = rp.id
),
pickup_data AS (
SELECT
si.*,
-- Self-pickup: restore "My Business Card" name
CASE WHEN si.is_self_pickup AND si.is_business_card
THEN 'My Business Card'
ELSE si.final_prop_name
END AS resolved_prop_name,
-- Self-pickup: restore is_owner_card flag
CASE WHEN si.is_self_pickup AND si.is_business_card
THEN si.server_state || '{"is_owner_card": true}'::jsonb
ELSE si.server_state
END AS resolved_server_state,
-- Self-pickup: clear snapshot (owner doesn't need it)
CASE WHEN si.is_self_pickup AND si.is_business_card
THEN '{}'::jsonb
ELSE si.server_private_state
END AS resolved_private_state
FROM source_info si
),
-- Check for existing business card from same giver (not self-pickup)
existing_card AS (
SELECT inv.id
FROM auth.inventory inv, pickup_data pd
WHERE inv.user_id = $2
AND pd.is_business_card = true
AND NOT pd.is_self_pickup
AND pd.giver_id IS NOT NULL
AND (inv.server_private_state->'snapshot'->>'user_id')::uuid = pd.giver_id
),
-- Update existing card if found (refresh snapshot)
updated_card AS (
UPDATE auth.inventory inv
SET
server_private_state = pd.resolved_private_state,
acquired_at = now()
FROM pickup_data pd, existing_card ec
WHERE inv.id = ec.id
RETURNING inv.id, inv.server_prop_id, inv.realm_prop_id, inv.prop_name, inv.prop_asset_path, inv.layer,
inv.is_transferable, inv.is_portable, inv.is_droppable, inv.origin, inv.acquired_at,
inv.server_state, inv.realm_state, inv.user_state,
inv.server_private_state, inv.realm_private_state, inv.user_private_state
),
-- Insert new item only if no existing card was updated
inserted_item AS (
INSERT INTO auth.inventory (
user_id,
@ -349,60 +715,63 @@ pub async fn pick_up_loose_prop<'e>(
is_droppable,
provenance,
acquired_at,
-- Transfer public state from loose prop
server_state,
realm_state,
user_state,
-- Initialize private state to empty (cleared for new owner)
server_private_state,
realm_private_state,
user_private_state
)
SELECT
$2,
si.server_prop_id,
si.realm_prop_id,
si.prop_name,
si.prop_asset_path,
si.layer,
pd.server_prop_id,
pd.realm_prop_id,
pd.resolved_prop_name,
pd.prop_asset_path,
pd.layer,
'server_library'::server.prop_origin,
COALESCE(si.is_transferable, true),
COALESCE(si.is_portable, true),
COALESCE(si.is_droppable, true),
COALESCE(pd.is_transferable, true),
COALESCE(pd.is_portable, true),
COALESCE(pd.is_droppable, true),
'[]'::jsonb,
now(),
-- Transfer public state
si.server_state,
si.realm_state,
si.user_state,
-- Private state cleared for new owner
'{}'::jsonb,
pd.resolved_server_state,
pd.realm_state,
pd.user_state,
pd.resolved_private_state,
'{}'::jsonb,
'{}'::jsonb
FROM source_info si
FROM pickup_data pd
WHERE NOT EXISTS (SELECT 1 FROM updated_card)
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer,
is_transferable, is_portable, is_droppable, origin, acquired_at,
server_state, realm_state, user_state,
server_private_state, realm_private_state, user_private_state
)
SELECT
ii.id,
COALESCE(ii.server_prop_id, ii.realm_prop_id) as prop_id,
ii.prop_name,
ii.prop_asset_path,
ii.layer,
ii.is_transferable,
ii.is_portable,
ii.is_droppable,
ii.origin,
ii.acquired_at,
ii.server_state,
ii.realm_state,
ii.user_state,
ii.server_private_state,
ii.realm_private_state,
ii.user_private_state
FROM inserted_item ii
fr.id,
COALESCE(fr.server_prop_id, fr.realm_prop_id) as prop_id,
fr.prop_name,
fr.prop_asset_path,
fr.layer,
fr.is_transferable,
fr.is_portable,
fr.is_droppable,
pd.is_unique,
fr.origin,
fr.acquired_at,
fr.server_state,
fr.realm_state,
fr.user_state,
fr.server_private_state,
fr.realm_private_state,
fr.user_private_state
FROM (
SELECT * FROM updated_card
UNION ALL
SELECT * FROM inserted_item
) fr
CROSS JOIN pickup_data pd
"#,
)
.bind(loose_prop_id)
@ -465,7 +834,7 @@ pub async fn update_loose_prop_scale<'e>(
u.dropped_by,
u.expires_at,
u.created_at,
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
u.is_locked,
u.locked_by,
@ -504,7 +873,7 @@ pub async fn get_loose_prop_by_id<'e>(
lp.dropped_by,
lp.expires_at,
lp.created_at,
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
lp.is_locked,
lp.locked_by,
@ -567,7 +936,7 @@ pub async fn move_loose_prop<'e>(
u.dropped_by,
u.expires_at,
u.created_at,
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
u.is_locked,
u.locked_by,
@ -630,7 +999,7 @@ pub async fn lock_loose_prop<'e>(
u.dropped_by,
u.expires_at,
u.created_at,
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
u.is_locked,
u.locked_by,
@ -691,7 +1060,7 @@ pub async fn unlock_loose_prop<'e>(
u.dropped_by,
u.expires_at,
u.created_at,
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
u.is_locked,
u.locked_by,

View file

@ -5,7 +5,7 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{ServerConfig, UpdateServerConfigRequest};
use crate::models::{ServerConfig, UpdateServerConfigRequest, UpdateServerDefaultAvatarsRequest};
use chattyness_error::AppError;
/// The fixed UUID for the singleton server config row.
@ -91,3 +91,34 @@ pub async fn update_server_config(
Ok(config)
}
/// Update server default avatars.
pub async fn update_server_default_avatars(
pool: &PgPool,
req: &UpdateServerDefaultAvatarsRequest,
) -> Result<(), AppError> {
sqlx::query(
r#"
UPDATE server.config SET
default_avatar_neutral_child = $1,
default_avatar_neutral_adult = $2,
default_avatar_male_child = $3,
default_avatar_male_adult = $4,
default_avatar_female_child = $5,
default_avatar_female_adult = $6,
updated_at = now()
WHERE id = $7
"#,
)
.bind(req.default_avatar_neutral_child)
.bind(req.default_avatar_neutral_adult)
.bind(req.default_avatar_male_child)
.bind(req.default_avatar_male_adult)
.bind(req.default_avatar_female_child)
.bind(req.default_avatar_female_adult)
.bind(server_config_id())
.execute(pool)
.await?;
Ok(())
}

View file

@ -724,3 +724,461 @@ pub fn generate_guest_name() -> String {
let number: u32 = rng.gen_range(10000..100000);
format!("Guest_{}", number)
}
// =============================================================================
// Profile Queries
// =============================================================================
use crate::models::{
AvatarSource, BusinessCardSnapshot, CreateContactRequest, CreateOrganizationRequest,
ProfileVisibility, UpdateContactRequest, UpdateOrganizationRequest,
UpdateProfileRequest, UserContact, UserOrganization, UserProfile,
};
/// Get the full user profile for editing.
pub async fn get_user_profile(pool: &PgPool, user_id: Uuid) -> Result<Option<UserProfile>, AppError> {
let profile = sqlx::query_as::<_, UserProfile>(
r#"
SELECT
id,
username,
email,
phone,
display_name,
name_first,
name_last,
summary,
homepage,
bio,
avatar_source,
profile_visibility,
contacts_visibility,
organizations_visibility,
created_at,
updated_at
FROM auth.users
WHERE id = $1 AND status = 'active'
"#,
)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(profile)
}
/// Get a user's public profile by username.
/// Returns the full profile data - the caller is responsible for filtering
/// based on visibility settings and viewer authentication.
pub async fn get_public_profile_by_username(
pool: &PgPool,
username: &str,
) -> Result<Option<UserProfile>, AppError> {
let profile = sqlx::query_as::<_, UserProfile>(
r#"
SELECT
id,
username,
email,
phone,
display_name,
name_first,
name_last,
summary,
homepage,
bio,
avatar_source,
profile_visibility,
contacts_visibility,
organizations_visibility,
created_at,
updated_at
FROM auth.users
WHERE username = $1 AND status = 'active'
"#,
)
.bind(username)
.fetch_optional(pool)
.await?;
Ok(profile)
}
/// Update basic profile fields using a connection (for RLS support).
pub async fn update_user_profile_conn(
conn: &mut PgConnection,
user_id: Uuid,
req: &UpdateProfileRequest,
) -> Result<(), AppError> {
sqlx::query(
r#"
UPDATE auth.users
SET
display_name = $2,
name_first = $3,
name_last = $4,
summary = $5,
homepage = $6,
bio = $7,
phone = $8,
updated_at = now()
WHERE id = $1
"#,
)
.bind(user_id)
.bind(&req.display_name)
.bind(&req.name_first)
.bind(&req.name_last)
.bind(&req.summary)
.bind(&req.homepage)
.bind(&req.bio)
.bind(&req.phone)
.execute(conn)
.await?;
Ok(())
}
/// Update visibility settings using a connection (for RLS support).
pub async fn update_visibility_conn(
conn: &mut PgConnection,
user_id: Uuid,
profile_visibility: ProfileVisibility,
contacts_visibility: ProfileVisibility,
organizations_visibility: ProfileVisibility,
avatar_source: AvatarSource,
) -> Result<(), AppError> {
sqlx::query(
r#"
UPDATE auth.users
SET
profile_visibility = $2,
contacts_visibility = $3,
organizations_visibility = $4,
avatar_source = $5,
updated_at = now()
WHERE id = $1
"#,
)
.bind(user_id)
.bind(profile_visibility)
.bind(contacts_visibility)
.bind(organizations_visibility)
.bind(avatar_source)
.execute(conn)
.await?;
Ok(())
}
/// Get a user's profile snapshot for use in business cards.
///
/// Captures the essential profile fields at a point in time.
/// Returns None if the user doesn't exist or is not active.
pub async fn get_user_business_card_snapshot(
pool: &PgPool,
user_id: Uuid,
) -> Result<Option<BusinessCardSnapshot>, AppError> {
#[derive(sqlx::FromRow)]
struct SnapshotRow {
id: Uuid,
username: String,
display_name: String,
name_first: Option<String>,
name_last: Option<String>,
email: Option<String>,
phone: Option<String>,
summary: Option<String>,
homepage: Option<String>,
}
let row = sqlx::query_as::<_, SnapshotRow>(
r#"
SELECT
id,
username,
display_name,
name_first,
name_last,
email,
phone,
summary,
homepage
FROM auth.users
WHERE id = $1 AND status = 'active'
"#,
)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| BusinessCardSnapshot {
user_id: r.id,
username: r.username,
display_name: r.display_name,
name_first: r.name_first,
name_last: r.name_last,
email: r.email,
phone: r.phone,
summary: r.summary,
homepage: r.homepage,
captured_at: chrono::Utc::now(),
}))
}
// =============================================================================
// Contact Queries
// =============================================================================
/// List all contacts for a user.
pub async fn list_user_contacts(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserContact>, AppError> {
let contacts = sqlx::query_as::<_, UserContact>(
r#"
SELECT
id,
user_id,
platform,
value,
label,
sort_order,
created_at,
updated_at
FROM auth.user_contacts
WHERE user_id = $1
ORDER BY sort_order ASC, created_at ASC
"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(contacts)
}
/// Create a new contact using a connection (for RLS support).
pub async fn create_contact_conn(
conn: &mut PgConnection,
user_id: Uuid,
req: &CreateContactRequest,
) -> Result<Uuid, AppError> {
// Get the next sort order
let (max_order,): (Option<i16>,) = sqlx::query_as(
"SELECT MAX(sort_order) FROM auth.user_contacts WHERE user_id = $1",
)
.bind(user_id)
.fetch_one(&mut *conn)
.await?;
let sort_order = max_order.unwrap_or(0) + 1;
let (contact_id,): (Uuid,) = sqlx::query_as(
r#"
INSERT INTO auth.user_contacts (user_id, platform, value, label, sort_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
"#,
)
.bind(user_id)
.bind(req.platform)
.bind(&req.value)
.bind(&req.label)
.bind(sort_order)
.fetch_one(&mut *conn)
.await?;
Ok(contact_id)
}
/// Update an existing contact using a connection (for RLS support).
pub async fn update_contact_conn(
conn: &mut PgConnection,
user_id: Uuid,
contact_id: Uuid,
req: &UpdateContactRequest,
) -> Result<(), AppError> {
let result = sqlx::query(
r#"
UPDATE auth.user_contacts
SET
platform = $3,
value = $4,
label = $5,
sort_order = $6,
updated_at = now()
WHERE id = $1 AND user_id = $2
"#,
)
.bind(contact_id)
.bind(user_id)
.bind(req.platform)
.bind(&req.value)
.bind(&req.label)
.bind(req.sort_order)
.execute(conn)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Contact not found".to_string()));
}
Ok(())
}
/// Delete a contact using a connection (for RLS support).
pub async fn delete_contact_conn(
conn: &mut PgConnection,
user_id: Uuid,
contact_id: Uuid,
) -> Result<(), AppError> {
let result = sqlx::query(
"DELETE FROM auth.user_contacts WHERE id = $1 AND user_id = $2",
)
.bind(contact_id)
.bind(user_id)
.execute(conn)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Contact not found".to_string()));
}
Ok(())
}
// =============================================================================
// Organization Queries
// =============================================================================
/// List all organizations for a user.
pub async fn list_user_organizations(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserOrganization>, AppError> {
let organizations = sqlx::query_as::<_, UserOrganization>(
r#"
SELECT
id,
user_id,
wikidata_qid,
name,
role,
department,
start_date,
end_date,
is_current,
sort_order,
created_at,
updated_at
FROM auth.user_organizations
WHERE user_id = $1
ORDER BY is_current DESC, sort_order ASC, start_date DESC NULLS LAST
"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(organizations)
}
/// Create a new organization using a connection (for RLS support).
pub async fn create_organization_conn(
conn: &mut PgConnection,
user_id: Uuid,
req: &CreateOrganizationRequest,
) -> Result<Uuid, AppError> {
// Get the next sort order
let (max_order,): (Option<i16>,) = sqlx::query_as(
"SELECT MAX(sort_order) FROM auth.user_organizations WHERE user_id = $1",
)
.bind(user_id)
.fetch_one(&mut *conn)
.await?;
let sort_order = max_order.unwrap_or(0) + 1;
let (org_id,): (Uuid,) = sqlx::query_as(
r#"
INSERT INTO auth.user_organizations (
user_id, wikidata_qid, name, role, department,
start_date, end_date, is_current, sort_order
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
"#,
)
.bind(user_id)
.bind(&req.wikidata_qid)
.bind(&req.name)
.bind(&req.role)
.bind(&req.department)
.bind(req.start_date)
.bind(req.end_date)
.bind(req.is_current)
.bind(sort_order)
.fetch_one(&mut *conn)
.await?;
Ok(org_id)
}
/// Update an existing organization using a connection (for RLS support).
pub async fn update_organization_conn(
conn: &mut PgConnection,
user_id: Uuid,
org_id: Uuid,
req: &UpdateOrganizationRequest,
) -> Result<(), AppError> {
let result = sqlx::query(
r#"
UPDATE auth.user_organizations
SET
wikidata_qid = $3,
name = $4,
role = $5,
department = $6,
start_date = $7,
end_date = $8,
is_current = $9,
sort_order = $10,
updated_at = now()
WHERE id = $1 AND user_id = $2
"#,
)
.bind(org_id)
.bind(user_id)
.bind(&req.wikidata_qid)
.bind(&req.name)
.bind(&req.role)
.bind(&req.department)
.bind(req.start_date)
.bind(req.end_date)
.bind(req.is_current)
.bind(req.sort_order)
.execute(conn)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Organization not found".to_string()));
}
Ok(())
}
/// Delete an organization using a connection (for RLS support).
pub async fn delete_organization_conn(
conn: &mut PgConnection,
user_id: Uuid,
org_id: Uuid,
) -> Result<(), AppError> {
let result = sqlx::query(
"DELETE FROM auth.user_organizations WHERE id = $1 AND user_id = $2",
)
.bind(org_id)
.bind(user_id)
.execute(conn)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Organization not found".to_string()));
}
Ok(())
}

View file

@ -81,6 +81,14 @@ pub enum ClientMessage {
inventory_item_id: Uuid,
},
/// Copy and drop a prop from inventory to the canvas.
/// Creates a copy on the scene while keeping the original in inventory.
/// Only works for props where `is_unique == false` and `is_droppable == true`.
CopyAndDropProp {
/// Inventory item ID to copy and drop.
inventory_item_id: Uuid,
},
/// Pick up a loose prop from the canvas.
PickUpProp {
/// Loose prop ID to pick up.
@ -347,6 +355,8 @@ pub enum ServerMessage {
MemberIdentityUpdated {
/// User ID of the member.
user_id: Uuid,
/// New username (for profile URLs).
username: String,
/// New display name.
display_name: String,
/// Whether the member is still a guest.

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=(

View file

@ -337,6 +337,58 @@ CREATE POLICY auth_inventory_view ON auth.inventory
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.inventory TO chattyness_app;
-- auth.user_contacts
ALTER TABLE auth.user_contacts ENABLE ROW LEVEL SECURITY;
-- Full access to own contacts
CREATE POLICY auth_user_contacts_own ON auth.user_contacts
FOR ALL TO chattyness_app
USING (user_id = public.current_user_id())
WITH CHECK (user_id = public.current_user_id());
-- Visibility-based SELECT for other users' contacts (uses parent user's contacts_visibility)
CREATE POLICY auth_user_contacts_view ON auth.user_contacts
FOR SELECT TO chattyness_app
USING (
user_id = public.current_user_id()
OR EXISTS (
SELECT 1 FROM auth.users u WHERE u.id = user_contacts.user_id
AND (
u.contacts_visibility = 'public'
OR (u.contacts_visibility = 'members' AND public.current_user_id() IS NOT NULL)
OR (u.contacts_visibility = 'friends' AND auth.are_friends(u.id, public.current_user_id()))
)
)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.user_contacts TO chattyness_app;
-- auth.user_organizations
ALTER TABLE auth.user_organizations ENABLE ROW LEVEL SECURITY;
-- Full access to own organizations
CREATE POLICY auth_user_organizations_own ON auth.user_organizations
FOR ALL TO chattyness_app
USING (user_id = public.current_user_id())
WITH CHECK (user_id = public.current_user_id());
-- Visibility-based SELECT for other users' organizations (uses parent user's organizations_visibility)
CREATE POLICY auth_user_organizations_view ON auth.user_organizations
FOR SELECT TO chattyness_app
USING (
user_id = public.current_user_id()
OR EXISTS (
SELECT 1 FROM auth.users u WHERE u.id = user_organizations.user_id
AND (
u.organizations_visibility = 'public'
OR (u.organizations_visibility = 'members' AND public.current_user_id() IS NOT NULL)
OR (u.organizations_visibility = 'friends' AND auth.are_friends(u.id, public.current_user_id()))
)
)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.user_organizations TO chattyness_app;
-- auth.avatars
ALTER TABLE auth.avatars ENABLE ROW LEVEL SECURITY;

View file

@ -16,6 +16,7 @@ CREATE TABLE auth.users (
username auth.username NOT NULL,
email auth.email,
phone TEXT, -- TODO: migrate to auth.phone_number domain
password_hash TEXT,
auth_provider auth.auth_provider NOT NULL DEFAULT 'local',
oauth_id TEXT,
@ -24,6 +25,16 @@ CREATE TABLE auth.users (
bio TEXT,
avatar_url public.url,
-- HOSS membership profile fields
name_first TEXT,
name_last TEXT,
summary TEXT, -- Brief one-liner tagline
homepage public.url,
avatar_source auth.avatar_source NOT NULL DEFAULT 'local',
profile_visibility auth.profile_visibility NOT NULL DEFAULT 'public',
contacts_visibility auth.profile_visibility NOT NULL DEFAULT 'members',
organizations_visibility auth.profile_visibility NOT NULL DEFAULT 'members',
-- User preferences for default avatar selection
birthday DATE,
gender_preference auth.gender_preference NOT NULL DEFAULT 'gender_neutral',
@ -127,6 +138,71 @@ CREATE INDEX idx_auth_friendships_friend_a ON auth.friendships (friend_a);
CREATE INDEX idx_auth_friendships_friend_b ON auth.friendships (friend_b);
CREATE INDEX idx_auth_friendships_pending ON auth.friendships (is_accepted) WHERE is_accepted = false;
-- =============================================================================
-- User Contacts (Social/Contact Links)
-- =============================================================================
CREATE TABLE auth.user_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
platform auth.contact_platform NOT NULL,
value TEXT NOT NULL,
label TEXT, -- Optional display label
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_auth_user_contacts_platform UNIQUE (user_id, platform, value),
CONSTRAINT chk_auth_user_contacts_value_nonempty CHECK (length(trim(value)) > 0)
);
COMMENT ON TABLE auth.user_contacts IS 'User social/contact platform links';
COMMENT ON COLUMN auth.user_contacts.platform IS 'Type of contact (discord, github, linkedin, etc.)';
COMMENT ON COLUMN auth.user_contacts.value IS 'Platform-specific identifier (handle, URL, phone, etc.)';
COMMENT ON COLUMN auth.user_contacts.label IS 'Optional user-friendly display label';
COMMENT ON COLUMN auth.user_contacts.sort_order IS 'Display order (lower = first)';
CREATE INDEX idx_auth_user_contacts_user ON auth.user_contacts (user_id);
CREATE INDEX idx_auth_user_contacts_platform ON auth.user_contacts (platform);
-- =============================================================================
-- User Organizations (Affiliations)
-- =============================================================================
CREATE TABLE auth.user_organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
wikidata_qid public.wikidata_qid, -- Wikidata Q-number for org
name TEXT NOT NULL, -- Display name (may differ from Wikidata)
role TEXT, -- User's role/title
department TEXT, -- Department/division
start_date DATE,
end_date DATE,
is_current BOOLEAN NOT NULL DEFAULT true,
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_auth_user_orgs_name_nonempty CHECK (length(trim(name)) > 0),
CONSTRAINT chk_auth_user_orgs_dates CHECK (start_date IS NULL OR end_date IS NULL OR start_date <= end_date)
);
COMMENT ON TABLE auth.user_organizations IS 'User organizational affiliations';
COMMENT ON COLUMN auth.user_organizations.wikidata_qid IS 'Wikidata Q-number for standardized organization lookup';
COMMENT ON COLUMN auth.user_organizations.name IS 'Display name for the organization';
COMMENT ON COLUMN auth.user_organizations.role IS 'User role/title at the organization';
COMMENT ON COLUMN auth.user_organizations.is_current IS 'Whether this is a current affiliation';
CREATE INDEX idx_auth_user_organizations_user ON auth.user_organizations (user_id);
CREATE INDEX idx_auth_user_organizations_wikidata ON auth.user_organizations (wikidata_qid)
WHERE wikidata_qid IS NOT NULL;
CREATE INDEX idx_auth_user_organizations_current ON auth.user_organizations (user_id, is_current)
WHERE is_current = true;
-- =============================================================================
-- Block List
-- =============================================================================

View file

@ -207,6 +207,9 @@ CREATE TABLE scene.loose_props (
server_prop_id UUID REFERENCES server.props(id) ON DELETE CASCADE,
realm_prop_id UUID REFERENCES realm.props(id) ON DELETE CASCADE,
-- Custom prop name (overrides source prop name when set)
prop_name TEXT,
-- Position in scene (PostGIS point, SRID 0)
position public.virtual_point NOT NULL,
@ -223,11 +226,15 @@ CREATE TABLE scene.loose_props (
is_locked BOOLEAN NOT NULL DEFAULT false,
locked_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
-- Public state columns (loose props only have public state)
-- Public state columns
server_state JSONB NOT NULL DEFAULT '{}',
realm_state JSONB NOT NULL DEFAULT '{}',
user_state JSONB NOT NULL DEFAULT '{}',
-- Private state (transferred to picker, not visible while on ground)
-- Used for business card snapshots captured at drop time
server_private_state JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Must reference exactly one source
@ -238,6 +245,7 @@ CREATE TABLE scene.loose_props (
);
COMMENT ON TABLE scene.loose_props IS 'Props dropped in instances that can be picked up';
COMMENT ON COLUMN scene.loose_props.prop_name IS 'Custom prop name overriding the source prop name (used for renamed items like business cards)';
COMMENT ON COLUMN scene.loose_props.position IS 'Location in scene as PostGIS point (SRID 0)';
COMMENT ON COLUMN scene.loose_props.expires_at IS 'When prop auto-decays (NULL = permanent)';
COMMENT ON COLUMN scene.loose_props.is_locked IS 'If true, only moderators can move/scale/pickup this prop';

View file

@ -60,6 +60,14 @@ CREATE TRIGGER trg_auth_active_avatars_updated_at
BEFORE UPDATE ON auth.active_avatars
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER trg_auth_user_contacts_updated_at
BEFORE UPDATE ON auth.user_contacts
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER trg_auth_user_organizations_updated_at
BEFORE UPDATE ON auth.user_organizations
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- =============================================================================
-- Realm Schema Triggers
-- =============================================================================

View file

@ -179,6 +179,63 @@ CREATE TYPE auth.auth_provider AS ENUM (
);
COMMENT ON TYPE auth.auth_provider IS 'Authentication method used for account';
-- Avatar source for profile pictures
CREATE TYPE auth.avatar_source AS ENUM (
'local', -- Uploaded to local server
'discord', -- Discord avatar
'github', -- GitHub avatar
'google_scholar', -- Google Scholar profile picture
'libravatar', -- Libravatar service
'gravatar' -- Gravatar service
);
COMMENT ON TYPE auth.avatar_source IS 'Source of profile avatar image';
-- Contact/social platform identifiers
CREATE TYPE auth.contact_platform AS ENUM (
'discord',
'linkedin',
'facebook',
'twitter',
'instagram',
'threads',
'pinterest',
'youtube',
'spotify',
'substack',
'patreon',
'linktree',
'github',
'gitlab',
'gitea',
'codeberg',
'stackexchange',
'crates_io',
'pause_cpan',
'npm',
'devpost',
'google_scholar',
'huggingface',
'steam',
'amazon_author',
'openstreetmap',
'wikidata',
'wikimedia_commons',
'wikipedia',
'phone',
'email_alt',
'website'
);
COMMENT ON TYPE auth.contact_platform IS 'Social/contact platform identifiers for user profiles';
-- Profile visibility levels
CREATE TYPE auth.profile_visibility AS ENUM (
'public', -- Visible to everyone
'members', -- Visible to logged-in members only
'friends', -- Visible to friends only
'private' -- Visible only to self
);
COMMENT ON TYPE auth.profile_visibility IS 'Visibility level for profile information';
-- =============================================================================
-- Realm Enums
-- =============================================================================

View file

@ -58,6 +58,31 @@ CREATE DOMAIN public.asset_path AS TEXT
CHECK (VALUE ~ '^[a-zA-Z0-9/_.-]+$' AND length(VALUE) <= 500);
COMMENT ON DOMAIN public.asset_path IS 'Valid asset storage path';
-- Wikidata QID (Q-number identifier)
CREATE DOMAIN public.wikidata_qid AS TEXT
CHECK (VALUE ~ '^Q[1-9][0-9]*$');
COMMENT ON DOMAIN public.wikidata_qid IS 'Wikidata Q-number identifier (e.g., Q42 for Douglas Adams)';
-- =============================================================================
-- Auth Domains
-- =============================================================================
-- Phone number (RFC 3966 tel: URI format or E.164)
-- Accepts formats: +1234567890, tel:+1-234-567-8901, etc.
CREATE DOMAIN auth.phone_number AS TEXT
CHECK (VALUE ~ '^(\+|tel:\+?)[0-9\-. ()]+$' AND length(VALUE) <= 30);
COMMENT ON DOMAIN auth.phone_number IS 'Phone number in RFC 3966 or E.164 format';
-- Discord snowflake ID (unsigned 64-bit integer as BIGINT)
CREATE DOMAIN auth.discord_id AS BIGINT
CHECK (VALUE > 0);
COMMENT ON DOMAIN auth.discord_id IS 'Discord snowflake identifier (unsigned 64-bit)';
-- Generic social handle (username on external platforms)
CREATE DOMAIN auth.social_handle AS TEXT
CHECK (length(trim(VALUE)) BETWEEN 1 AND 100);
COMMENT ON DOMAIN auth.social_handle IS 'Social platform username/handle (1-100 characters)';
-- =============================================================================
-- PostGIS Spatial Domains (SRID 0 for virtual 2D world)
-- =============================================================================

View file

@ -284,6 +284,12 @@
font-size: 0.75rem;
color: #aaa;
text-transform: capitalize;
text-decoration: none;
}
.prop-name:hover {
color: #4ECDC4;
text-decoration: underline;
}
/* Selected prop display */
@ -420,6 +426,11 @@
<div class="prop-items" id="tea-props" role="group" aria-label="Tea props"></div>
</div>
<div class="prop-category">
<h3>Identification</h3>
<div class="prop-items" id="id-props" role="group" aria-label="Identification props"></div>
</div>
<div class="prop-category">
<h3>Misc</h3>
<div class="prop-items" id="misc-props" role="group" aria-label="Miscellaneous props"></div>
@ -557,7 +568,8 @@
coffee: ['espresso', 'latte', 'iced', 'frenchpress', 'pourover', 'turkish', 'cup-empty'],
soda: ['cola', 'lemonlime', 'orange', 'grape', 'rootbeer'],
tea: ['iced', 'pot', 'cup', 'cup-empty', 'bag'],
misc: ['iou', 'signed-dollar', 'thankyou', 'yousuck', 'businesscard'],
misc: ['iou', 'signed-dollar', 'thankyou', 'yousuck'],
id: ['businesscard', 'businesscard-box', 'card', 'dogtags'],
goodpol: ['cccp', 'china', 'palestine'],
screen: ['projector-screen', 'projector-screen-with-stand', 'projector-remote-control'],
keyboard: ['standard']
@ -583,9 +595,12 @@
preview.className = 'prop-preview';
preview.innerHTML = svgText;
const label = document.createElement('span');
const label = document.createElement('a');
label.className = 'prop-name';
label.href = filename;
label.target = '_blank';
label.textContent = name.replace(/([A-Z])/g, ' $1').trim();
label.addEventListener('click', (e) => e.stopPropagation());
card.appendChild(preview);
card.appendChild(label);
@ -620,9 +635,12 @@
preview.className = 'prop-preview';
preview.innerHTML = svgText;
const label = document.createElement('span');
const label = document.createElement('a');
label.className = 'prop-name';
label.href = filename;
label.target = '_blank';
label.textContent = name.replace(/([A-Z])/g, ' $1').trim();
label.addEventListener('click', (e) => e.stopPropagation());
card.appendChild(preview);
card.appendChild(label);
@ -648,6 +666,7 @@
const coffeeContainer = document.getElementById('coffee-props');
const sodaContainer = document.getElementById('soda-props');
const teaContainer = document.getElementById('tea-props');
const idContainer = document.getElementById('id-props');
const miscContainer = document.getElementById('misc-props');
const goodpolContainer = document.getElementById('goodpol-props');
const screenContainer = document.getElementById('screen-props');
@ -665,6 +684,9 @@
for (const name of props.tea) {
await loadPropPreview('tea', name, teaContainer);
}
for (const name of props.id) {
await loadPropPreview('id', name, idContainer);
}
for (const name of props.misc) {
await loadPropPreview('misc', name, miscContainer);
}

View file

@ -1,30 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<g transform="scale(2.5)">
<defs>
<linearGradient id="cardface" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="100%" stop-color="#F0F0F0"/>
</linearGradient>
<filter id="cardshadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Business card with slight tilt -->
<g transform="rotate(-3, 24, 24)">
<!-- Card background - standard 3.5:2 ratio scaled -->
<rect x="5" y="12" width="38" height="22" rx="1.5" fill="url(#cardface)" filter="url(#cardshadow)" stroke="#DDD" stroke-width="0.3"/>
<!-- Face silhouette -->
<circle cx="10" cy="16.5" r="2" fill="#555"/>
<ellipse cx="10" cy="21" rx="3" ry="2" fill="#555"/>
<!-- Name text -->
<text x="16" y="18" font-family="Georgia, serif" font-size="4" font-weight="bold" fill="#1a1a2e">John Doe</text>
<!-- Title text -->
<text x="16" y="22" font-family="Arial, sans-serif" font-size="2.5" fill="#555">Software Engineer</text>
<!-- Contact line -->
<line x1="16" y1="25" x2="40" y2="25" stroke="#E0E0E0" stroke-width="0.3"/>
<!-- Email/phone placeholder -->
<text x="16" y="28" font-family="Arial, sans-serif" font-size="2" fill="#777">john@example.com</text>
<text x="16" y="31" font-family="Arial, sans-serif" font-size="2" fill="#777">+1 555-123-4567</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB