feat: profiles and /set profile, and id cards
* New functionality to set meta data on businesscards. * Can develop a user profile. * Business cards link to user profile.
This commit is contained in:
parent
cd8dfb94a3
commit
710985638f
35 changed files with 4932 additions and 435 deletions
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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(()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
pub mod auth;
|
||||
pub mod avatars;
|
||||
pub mod inventory;
|
||||
pub mod profile;
|
||||
pub mod realms;
|
||||
pub mod routes;
|
||||
pub mod scenes;
|
||||
|
|
|
|||
286
crates/chattyness-user-ui/src/api/profile.rs
Normal file
286
crates/chattyness-user-ui/src/api/profile.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! Profile API handlers.
|
||||
//!
|
||||
//! Endpoints for user profile management including basic info,
|
||||
//! visibility settings, contacts, and organizations.
|
||||
|
||||
use axum::{Json, extract::{Path, State}};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{
|
||||
CreateContactRequest, CreateOrganizationRequest, ProfileVisibility, PublicProfile,
|
||||
UpdateContactRequest, UpdateOrganizationRequest, UpdateProfileRequest,
|
||||
UpdateVisibilityRequest, UserContact, UserOrganization, UserProfile,
|
||||
},
|
||||
queries::users,
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
use crate::auth::{AuthUser, OptionalAuthUser, RlsConn};
|
||||
|
||||
// =============================================================================
|
||||
// Response Types
|
||||
// =============================================================================
|
||||
|
||||
/// Success response for profile operations.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Response with created resource ID.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct CreatedResponse {
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Profile Endpoints
|
||||
// =============================================================================
|
||||
|
||||
/// Get the current user's profile.
|
||||
pub async fn get_profile(
|
||||
State(pool): State<PgPool>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> Result<Json<UserProfile>, AppError> {
|
||||
let profile = users::get_user_profile(&pool, user.id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Profile not found".to_string()))?;
|
||||
|
||||
Ok(Json(profile))
|
||||
}
|
||||
|
||||
/// Update the current user's basic profile fields.
|
||||
pub async fn update_profile(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<SuccessResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Update profile using RLS connection
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
users::update_user_profile_conn(&mut *conn, user.id, &req).await?;
|
||||
|
||||
Ok(Json(SuccessResponse { success: true }))
|
||||
}
|
||||
|
||||
/// Update the current user's visibility settings.
|
||||
pub async fn update_visibility(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<UpdateVisibilityRequest>,
|
||||
) -> Result<Json<SuccessResponse>, AppError> {
|
||||
// Update visibility using RLS connection
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
users::update_visibility_conn(
|
||||
&mut *conn,
|
||||
user.id,
|
||||
req.profile_visibility,
|
||||
req.contacts_visibility,
|
||||
req.organizations_visibility,
|
||||
req.avatar_source,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(SuccessResponse { success: true }))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Contact Endpoints
|
||||
// =============================================================================
|
||||
|
||||
/// List all contacts for the current user.
|
||||
pub async fn list_contacts(
|
||||
State(pool): State<PgPool>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> Result<Json<Vec<UserContact>>, AppError> {
|
||||
let contacts = users::list_user_contacts(&pool, user.id).await?;
|
||||
Ok(Json(contacts))
|
||||
}
|
||||
|
||||
/// Create a new contact.
|
||||
pub async fn create_contact(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<CreateContactRequest>,
|
||||
) -> Result<Json<CreatedResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Create contact using RLS connection
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
let contact_id = users::create_contact_conn(&mut *conn, user.id, &req).await?;
|
||||
|
||||
Ok(Json(CreatedResponse { id: contact_id }))
|
||||
}
|
||||
|
||||
/// Update an existing contact.
|
||||
pub async fn update_contact(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(contact_id): Path<Uuid>,
|
||||
Json(req): Json<UpdateContactRequest>,
|
||||
) -> Result<Json<SuccessResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Update contact using RLS connection
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
users::update_contact_conn(&mut *conn, user.id, contact_id, &req).await?;
|
||||
|
||||
Ok(Json(SuccessResponse { success: true }))
|
||||
}
|
||||
|
||||
/// Delete a contact.
|
||||
pub async fn delete_contact(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(contact_id): Path<Uuid>,
|
||||
) -> Result<Json<SuccessResponse>, AppError> {
|
||||
// Delete contact using RLS connection
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
users::delete_contact_conn(&mut *conn, user.id, contact_id).await?;
|
||||
|
||||
Ok(Json(SuccessResponse { success: true }))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Organization Endpoints
|
||||
// =============================================================================
|
||||
|
||||
/// List all organizations for the current user.
|
||||
pub async fn list_organizations(
|
||||
State(pool): State<PgPool>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> Result<Json<Vec<UserOrganization>>, AppError> {
|
||||
let organizations = users::list_user_organizations(&pool, user.id).await?;
|
||||
Ok(Json(organizations))
|
||||
}
|
||||
|
||||
/// Create a new organization.
|
||||
pub async fn create_organization(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<CreateOrganizationRequest>,
|
||||
) -> Result<Json<CreatedResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Create organization using RLS connection
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
let org_id = users::create_organization_conn(&mut *conn, user.id, &req).await?;
|
||||
|
||||
Ok(Json(CreatedResponse { id: org_id }))
|
||||
}
|
||||
|
||||
/// Update an existing organization.
|
||||
pub async fn update_organization(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(org_id): Path<Uuid>,
|
||||
Json(req): Json<UpdateOrganizationRequest>,
|
||||
) -> Result<Json<SuccessResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Update organization using RLS connection
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
users::update_organization_conn(&mut *conn, user.id, org_id, &req).await?;
|
||||
|
||||
Ok(Json(SuccessResponse { success: true }))
|
||||
}
|
||||
|
||||
/// Delete an organization.
|
||||
pub async fn delete_organization(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(org_id): Path<Uuid>,
|
||||
) -> Result<Json<SuccessResponse>, AppError> {
|
||||
// Delete organization using RLS connection
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
users::delete_organization_conn(&mut *conn, user.id, org_id).await?;
|
||||
|
||||
Ok(Json(SuccessResponse { success: true }))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public Profile Endpoints
|
||||
// =============================================================================
|
||||
|
||||
/// Get a user's public profile by username.
|
||||
/// Visibility settings determine what data is returned:
|
||||
/// - Public: visible to everyone
|
||||
/// - Members: visible to logged-in users
|
||||
/// - Friends: visible to user's friends (not yet implemented, treated as private)
|
||||
/// - Private: visible only to the profile owner
|
||||
pub async fn get_public_profile(
|
||||
State(pool): State<PgPool>,
|
||||
OptionalAuthUser(maybe_user): OptionalAuthUser,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<PublicProfile>, AppError> {
|
||||
// Fetch the user profile
|
||||
let profile = users::get_public_profile_by_username(&pool, &username)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
|
||||
|
||||
let viewer_id = maybe_user.as_ref().map(|u| u.id);
|
||||
let is_owner = viewer_id == Some(profile.id);
|
||||
let is_authenticated = viewer_id.is_some();
|
||||
|
||||
// Check if viewer can see the profile at all
|
||||
if !can_view(&profile.profile_visibility, is_owner, is_authenticated) {
|
||||
return Err(AppError::NotFound("User not found".to_string()));
|
||||
}
|
||||
|
||||
// Determine what to show based on visibility settings
|
||||
let contacts = if can_view(&profile.contacts_visibility, is_owner, is_authenticated) {
|
||||
Some(users::list_user_contacts(&pool, profile.id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let organizations = if can_view(&profile.organizations_visibility, is_owner, is_authenticated) {
|
||||
Some(users::list_user_organizations(&pool, profile.id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Email and phone follow contacts visibility
|
||||
let (email, phone) = if can_view(&profile.contacts_visibility, is_owner, is_authenticated) {
|
||||
(profile.email, profile.phone)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
Ok(Json(PublicProfile {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
email,
|
||||
phone,
|
||||
display_name: profile.display_name,
|
||||
summary: profile.summary,
|
||||
homepage: profile.homepage,
|
||||
bio: profile.bio,
|
||||
avatar_source: profile.avatar_source,
|
||||
contacts,
|
||||
organizations,
|
||||
member_since: profile.created_at,
|
||||
is_owner,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if a viewer can see content based on visibility setting.
|
||||
fn can_view(visibility: &ProfileVisibility, is_owner: bool, is_authenticated: bool) -> bool {
|
||||
if is_owner {
|
||||
return true;
|
||||
}
|
||||
match visibility {
|
||||
ProfileVisibility::Public => true,
|
||||
ProfileVisibility::Members => is_authenticated,
|
||||
ProfileVisibility::Friends => false, // TODO: implement friend checking
|
||||
ProfileVisibility::Private => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
use super::{auth, avatars, inventory, realms, scenes, websocket};
|
||||
use super::{auth, avatars, inventory, profile, realms, scenes, websocket};
|
||||
use crate::app::AppState;
|
||||
|
||||
/// Build the API router for user UI.
|
||||
|
|
@ -35,6 +35,35 @@ pub fn api_router() -> Router<AppState> {
|
|||
"/auth/preferences",
|
||||
axum::routing::put(auth::update_preferences),
|
||||
)
|
||||
// Profile routes
|
||||
.route(
|
||||
"/profile",
|
||||
get(profile::get_profile).put(profile::update_profile),
|
||||
)
|
||||
.route(
|
||||
"/profile/visibility",
|
||||
axum::routing::put(profile::update_visibility),
|
||||
)
|
||||
.route(
|
||||
"/profile/contacts",
|
||||
get(profile::list_contacts).post(profile::create_contact),
|
||||
)
|
||||
.route(
|
||||
"/profile/contacts/{id}",
|
||||
axum::routing::put(profile::update_contact)
|
||||
.delete(profile::delete_contact),
|
||||
)
|
||||
.route(
|
||||
"/profile/organizations",
|
||||
get(profile::list_organizations).post(profile::create_organization),
|
||||
)
|
||||
.route(
|
||||
"/profile/organizations/{id}",
|
||||
axum::routing::put(profile::update_organization)
|
||||
.delete(profile::delete_organization),
|
||||
)
|
||||
// Public profile route (no auth required for public profiles)
|
||||
.route("/users/{username}", get(profile::get_public_profile))
|
||||
// Realm routes (READ-ONLY)
|
||||
.route("/realms", get(realms::list_realms))
|
||||
.route("/realms/{slug}", get(realms::get_realm))
|
||||
|
|
|
|||
|
|
@ -711,6 +711,80 @@ async fn handle_socket(
|
|||
}
|
||||
}
|
||||
}
|
||||
ClientMessage::CopyAndDropProp { inventory_item_id } => {
|
||||
// Ensure instance exists for this scene (required for loose_props FK)
|
||||
// In this system, channel_id = scene_id
|
||||
if let Err(e) = loose_props::ensure_scene_instance(
|
||||
&mut *recv_conn,
|
||||
channel_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"[WS] Failed to ensure scene instance: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's current position for random offset
|
||||
let member_info = channel_members::get_channel_member(
|
||||
&mut *recv_conn,
|
||||
channel_id,
|
||||
user_id,
|
||||
realm_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(Some(member)) = member_info {
|
||||
// Generate random offset (within ~50 pixels)
|
||||
let offset_x = (rand::random::<f64>() - 0.5) * 100.0;
|
||||
let offset_y = (rand::random::<f64>() - 0.5) * 100.0;
|
||||
let pos_x = member.position_x + offset_x;
|
||||
let pos_y = member.position_y + offset_y;
|
||||
|
||||
match loose_props::copy_and_drop_prop(
|
||||
&mut *recv_conn,
|
||||
inventory_item_id,
|
||||
user_id,
|
||||
channel_id,
|
||||
pos_x,
|
||||
pos_y,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prop) => {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
"[WS] User {} copied and dropped prop {} at ({}, {})",
|
||||
user_id,
|
||||
prop.id,
|
||||
pos_x,
|
||||
pos_y
|
||||
);
|
||||
let _ =
|
||||
tx.send(ServerMessage::PropDropped { prop });
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Copy and drop prop failed: {:?}", e);
|
||||
let (code, message) = match &e {
|
||||
chattyness_error::AppError::Forbidden(msg) => (
|
||||
"PROP_NOT_COPYABLE".to_string(),
|
||||
msg.clone(),
|
||||
),
|
||||
chattyness_error::AppError::NotFound(msg) => {
|
||||
("PROP_NOT_FOUND".to_string(), msg.clone())
|
||||
}
|
||||
_ => (
|
||||
"COPY_DROP_FAILED".to_string(),
|
||||
format!("{:?}", e),
|
||||
),
|
||||
};
|
||||
let _ =
|
||||
tx.send(ServerMessage::Error { code, message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientMessage::DeleteProp { inventory_item_id } => {
|
||||
match inventory::drop_inventory_item(
|
||||
&mut *recv_conn,
|
||||
|
|
@ -1432,11 +1506,13 @@ async fn handle_socket(
|
|||
// Update the is_guest flag - critical for allowing
|
||||
// newly registered users to send whispers
|
||||
is_guest = updated_user.is_guest();
|
||||
let username = updated_user.username.clone();
|
||||
let display_name = updated_user.display_name.clone();
|
||||
|
||||
tracing::info!(
|
||||
"[WS] User {} refreshed identity: display_name={}, is_guest={}",
|
||||
"[WS] User {} refreshed identity: username={}, display_name={}, is_guest={}",
|
||||
user_id,
|
||||
username,
|
||||
display_name,
|
||||
is_guest
|
||||
);
|
||||
|
|
@ -1457,6 +1533,7 @@ async fn handle_socket(
|
|||
// Broadcast identity update to all channel members
|
||||
let _ = tx.send(ServerMessage::MemberIdentityUpdated {
|
||||
user_id,
|
||||
username,
|
||||
display_name,
|
||||
is_guest,
|
||||
});
|
||||
|
|
@ -1758,7 +1835,11 @@ async fn handle_socket(
|
|||
// Get loose prop state
|
||||
match loose_props::get_loose_prop_by_id(&pool, prop_id).await {
|
||||
Ok(Some(prop)) => {
|
||||
let action_hint = PropStateView::detect_action_hint(&prop.server_state);
|
||||
let action_hint = PropStateView::detect_action_hint_full(
|
||||
&prop.server_state,
|
||||
None,
|
||||
Some(&prop.prop_name),
|
||||
);
|
||||
// Get owner display name if prop was dropped by someone
|
||||
let owner_name = if let Some(dropped_by) = prop.dropped_by {
|
||||
users::get_user_by_id(&pool, dropped_by).await
|
||||
|
|
@ -1768,11 +1849,18 @@ async fn handle_socket(
|
|||
} else {
|
||||
None
|
||||
};
|
||||
// Clear is_owner_card flag for loose props - this flag
|
||||
// is only meaningful for inventory items (the owner's copy).
|
||||
// Loose props should never show "this is your card" message.
|
||||
let mut server_state = prop.server_state;
|
||||
if let Some(obj) = server_state.as_object_mut() {
|
||||
obj.remove("is_owner_card");
|
||||
}
|
||||
Ok(PropStateView {
|
||||
prop_id,
|
||||
prop_name: prop.prop_name,
|
||||
owner_display_name: owner_name,
|
||||
server_state: prop.server_state,
|
||||
server_state,
|
||||
// Realm state visible if user is in same realm
|
||||
realm_state: Some(prop.realm_state),
|
||||
user_state: prop.user_state,
|
||||
|
|
@ -1788,7 +1876,12 @@ async fn handle_socket(
|
|||
// Get inventory item state
|
||||
match inventory::get_inventory_item(&pool, prop_id, user_id).await {
|
||||
Ok(Some(item)) => {
|
||||
let action_hint = PropStateView::detect_action_hint(&item.server_state);
|
||||
// Use detect_action_hint_with_private to detect business cards
|
||||
// from server_private_state (received cards have snapshot there)
|
||||
let action_hint = PropStateView::detect_action_hint_with_private(
|
||||
&item.server_state,
|
||||
Some(&item.server_private_state),
|
||||
);
|
||||
Ok(PropStateView {
|
||||
prop_id,
|
||||
prop_name: item.prop_name,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ enum CommandMode {
|
|||
ShowingSlashHint,
|
||||
/// Showing mod command hints only (`/mod summon [nick|*]`).
|
||||
ShowingModHint,
|
||||
/// Showing set command hints (`/set profile`).
|
||||
ShowingSetHint,
|
||||
/// Showing emotion list popup.
|
||||
ShowingList,
|
||||
/// Showing scene list popup for teleport.
|
||||
|
|
@ -183,6 +185,9 @@ pub fn ChatInput(
|
|||
/// Callback to open registration modal.
|
||||
#[prop(optional)]
|
||||
on_open_register: Option<Callback<()>>,
|
||||
/// Callback to open profile page (non-guests only).
|
||||
#[prop(optional)]
|
||||
on_open_profile: Option<Callback<()>>,
|
||||
) -> impl IntoView {
|
||||
let (message, set_message) = signal(String::new());
|
||||
let (command_mode, set_command_mode) = signal(CommandMode::None);
|
||||
|
|
@ -382,9 +387,25 @@ pub fn ChatInput(
|
|||
&& !cmd.is_empty()
|
||||
&& "register".starts_with(&cmd);
|
||||
|
||||
// Check if typing set command (only for non-guests)
|
||||
// Show set hint when typing "/set" or "/set ..."
|
||||
let is_typing_set = !is_guest.get_untracked()
|
||||
&& (cmd == "set" || cmd.starts_with("set "));
|
||||
// Show /set in slash hints when just starting to type it (but not if closer to /setting)
|
||||
// Only show when typing exactly "se" to avoid conflict with /setting
|
||||
let is_partial_set = !is_guest.get_untracked()
|
||||
&& !cmd.is_empty()
|
||||
&& cmd.len() >= 2
|
||||
&& "set".starts_with(&cmd)
|
||||
&& cmd != "set"
|
||||
&& cmd != "s"; // /s goes to /setting
|
||||
|
||||
if is_complete_whisper || is_complete_teleport {
|
||||
// User is typing the argument part, no hint needed
|
||||
set_command_mode.set(CommandMode::None);
|
||||
} else if is_typing_set {
|
||||
// Show set-specific hint bar
|
||||
set_command_mode.set(CommandMode::ShowingSetHint);
|
||||
} else if is_typing_mod {
|
||||
// Show mod-specific hint bar
|
||||
set_command_mode.set(CommandMode::ShowingModHint);
|
||||
|
|
@ -404,6 +425,7 @@ pub fn ChatInput(
|
|||
|| cmd.starts_with("teleport ")
|
||||
|| is_partial_mod
|
||||
|| is_partial_register
|
||||
|| is_partial_set
|
||||
{
|
||||
set_command_mode.set(CommandMode::ShowingSlashHint);
|
||||
} else {
|
||||
|
|
@ -586,6 +608,21 @@ pub fn ChatInput(
|
|||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
// Autocomplete /set p to /set profile (only for non-guests)
|
||||
if !is_guest.get_untracked() && cmd.starts_with("set ") {
|
||||
let subcommand = cmd.strip_prefix("set ").unwrap_or("");
|
||||
if !subcommand.is_empty()
|
||||
&& "profile".starts_with(subcommand)
|
||||
&& subcommand != "profile"
|
||||
{
|
||||
set_message.set("/set profile".to_string());
|
||||
if let Some(input) = input_ref.get() {
|
||||
input.set_value("/set profile");
|
||||
}
|
||||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always prevent Tab from moving focus when in input
|
||||
ev.prevent_default();
|
||||
|
|
@ -599,6 +636,25 @@ pub fn ChatInput(
|
|||
if msg.starts_with('/') {
|
||||
let cmd = msg[1..].to_lowercase();
|
||||
|
||||
// /set profile - open profile page (non-guests only)
|
||||
// Must check before /setting since "set" is a prefix of "setting"
|
||||
if !is_guest.get_untracked() && cmd.starts_with("set ") {
|
||||
let subcommand = cmd.strip_prefix("set ").unwrap_or("").trim();
|
||||
if !subcommand.is_empty() && "profile".starts_with(subcommand) {
|
||||
if let Some(ref callback) = on_open_profile {
|
||||
callback.run(());
|
||||
}
|
||||
set_message.set(String::new());
|
||||
set_command_mode.set(CommandMode::None);
|
||||
if let Some(input) = input_ref.get() {
|
||||
input.set_value("");
|
||||
let _ = input.blur();
|
||||
}
|
||||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// /s, /se, /set, /sett, /setti, /settin, /setting, /settings
|
||||
if !cmd.is_empty() && ("setting".starts_with(&cmd) || cmd == "settings") {
|
||||
if let Some(ref callback) = on_open_settings {
|
||||
|
|
@ -883,6 +939,12 @@ pub fn ChatInput(
|
|||
<span class="text-purple-400">"/"</span>
|
||||
<span class="text-purple-400">"mod"</span>
|
||||
</Show>
|
||||
// Show /set hint for non-guests (details shown when typing /set)
|
||||
<Show when=move || !is_guest.get()>
|
||||
<span class="text-gray-600 mx-2">"|"</span>
|
||||
<span class="text-green-400">"/"</span>
|
||||
<span class="text-green-400">"set"</span>
|
||||
</Show>
|
||||
// Show /register hint for guests
|
||||
<Show when=move || is_guest.get()>
|
||||
<span class="text-gray-600 mx-2">"|"</span>
|
||||
|
|
@ -912,6 +974,16 @@ pub fn ChatInput(
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
// Set command hint bar (shown when typing /set)
|
||||
<Show when=move || command_mode.get() == CommandMode::ShowingSetHint>
|
||||
<div class="absolute bottom-full left-0 mb-1 px-3 py-1 bg-green-900/90 backdrop-blur-sm rounded text-sm">
|
||||
<span class="text-green-400">"/"</span>
|
||||
<span class="text-green-400">"set"</span>
|
||||
<span class="text-green-300">" profile"</span>
|
||||
<span class="text-green-500">" - edit your profile"</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// Emotion list popup
|
||||
<Show when=move || command_mode.get() == CommandMode::ShowingList>
|
||||
<EmoteListPopup
|
||||
|
|
|
|||
|
|
@ -351,3 +351,441 @@ fn event_target_checked(ev: &leptos::ev::Event) -> bool {
|
|||
fn event_target_checked(_ev: &leptos::ev::Event) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Select input (dropdown) field with label.
|
||||
#[component]
|
||||
pub fn SelectInput(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
options: Vec<(&'static str, &'static str)>,
|
||||
#[prop(optional)] help_text: &'static str,
|
||||
#[prop(default = false)] required: bool,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_change: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
let help_id = format!("{}-help", name);
|
||||
let has_help = !help_text.is_empty();
|
||||
|
||||
view! {
|
||||
<div class=format!("space-y-2 {}", class)>
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{if required {
|
||||
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
|
||||
}}
|
||||
</label>
|
||||
<select
|
||||
id=input_id
|
||||
name=name
|
||||
required=required
|
||||
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
||||
class="input-base"
|
||||
on:change=move |ev| on_change.run(event_target_value(&ev))
|
||||
>
|
||||
{options
|
||||
.into_iter()
|
||||
.map(|(val, display)| {
|
||||
let is_selected = Signal::derive(move || value.get() == val);
|
||||
view! { <option value=val selected=move || is_selected.get()>{display}</option> }
|
||||
})
|
||||
.collect_view()}
|
||||
</select>
|
||||
{if has_help {
|
||||
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Grouped select input (dropdown with optgroups) field with label.
|
||||
#[component]
|
||||
pub fn GroupedSelectInput(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
groups: Vec<(&'static str, Vec<(&'static str, &'static str)>)>,
|
||||
#[prop(optional)] help_text: &'static str,
|
||||
#[prop(default = false)] required: bool,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_change: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
let help_id = format!("{}-help", name);
|
||||
let has_help = !help_text.is_empty();
|
||||
|
||||
view! {
|
||||
<div class=format!("space-y-2 {}", class)>
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{if required {
|
||||
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
|
||||
}}
|
||||
</label>
|
||||
<select
|
||||
id=input_id
|
||||
name=name
|
||||
required=required
|
||||
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
||||
class="input-base"
|
||||
on:change=move |ev| on_change.run(event_target_value(&ev))
|
||||
>
|
||||
{groups
|
||||
.into_iter()
|
||||
.map(|(group_label, options)| {
|
||||
view! {
|
||||
<optgroup label=group_label>
|
||||
{options
|
||||
.into_iter()
|
||||
.map(|(val, display)| {
|
||||
let is_selected = Signal::derive(move || value.get() == val);
|
||||
view! {
|
||||
<option value=val selected=move || is_selected.get()>
|
||||
{display}
|
||||
</option>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</optgroup>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</select>
|
||||
{if has_help {
|
||||
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Searchable select with grouped options (combobox pattern).
|
||||
/// Allows typing to filter options and selecting from grouped dropdown.
|
||||
#[component]
|
||||
pub fn SearchableSelect(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
groups: Vec<(&'static str, Vec<(&'static str, &'static str)>)>,
|
||||
#[prop(optional)] help_text: &'static str,
|
||||
#[prop(default = false)] required: bool,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_change: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
let listbox_id = format!("{}-listbox", name);
|
||||
let help_id = format!("{}-help", name);
|
||||
let has_help = !help_text.is_empty();
|
||||
|
||||
// State for the search/filter text
|
||||
let (search_text, set_search_text) = signal(String::new());
|
||||
let (is_open, set_is_open) = signal(false);
|
||||
let (focused_index, set_focused_index) = signal(Option::<usize>::None);
|
||||
|
||||
// Clone groups for use in multiple closures
|
||||
let groups_for_label = groups.clone();
|
||||
let groups_for_filter = groups.clone();
|
||||
|
||||
// Get the display label for current value
|
||||
let display_label = Signal::derive(move || {
|
||||
let current = value.get();
|
||||
for (_, opts) in groups_for_label.iter() {
|
||||
for (val, label) in opts.iter() {
|
||||
if *val == current.as_str() {
|
||||
return label.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
current
|
||||
});
|
||||
|
||||
// Filter options based on search text
|
||||
let filtered_groups = Signal::derive(move || {
|
||||
let search = search_text.get().to_lowercase();
|
||||
if search.is_empty() {
|
||||
return groups_for_filter.clone();
|
||||
}
|
||||
groups_for_filter
|
||||
.iter()
|
||||
.filter_map(|(group_label, options)| {
|
||||
let filtered: Vec<_> = options
|
||||
.iter()
|
||||
.filter(|(_, label)| label.to_lowercase().contains(&search))
|
||||
.cloned()
|
||||
.collect();
|
||||
if filtered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((*group_label, filtered))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Count total filtered options for keyboard navigation
|
||||
let filtered_count = Signal::derive(move || {
|
||||
filtered_groups
|
||||
.get()
|
||||
.iter()
|
||||
.map(|(_, opts)| opts.len())
|
||||
.sum::<usize>()
|
||||
});
|
||||
|
||||
// Get the option at a given flat index from filtered results
|
||||
let get_option_at_index = move |index: usize| -> Option<(&'static str, &'static str)> {
|
||||
let groups = filtered_groups.get();
|
||||
let mut current = 0;
|
||||
for (_, opts) in groups.iter() {
|
||||
for (val, label) in opts.iter() {
|
||||
if current == index {
|
||||
return Some((*val, *label));
|
||||
}
|
||||
current += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let handle_select = move |val: &str| {
|
||||
on_change.run(val.to_string());
|
||||
set_search_text.set(String::new());
|
||||
set_is_open.set(false);
|
||||
set_focused_index.set(None);
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class=format!("space-y-2 relative {}", class)>
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{if required {
|
||||
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
|
||||
}}
|
||||
</label>
|
||||
|
||||
// Hidden select for form submission
|
||||
<input type="hidden" name=name prop:value=move || value.get() />
|
||||
|
||||
// Combobox input
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id=input_id
|
||||
role="combobox"
|
||||
aria-expanded=move || is_open.get()
|
||||
aria-haspopup="listbox"
|
||||
aria-controls=listbox_id.clone()
|
||||
aria-autocomplete="list"
|
||||
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
||||
autocomplete="off"
|
||||
class="input-base pr-8"
|
||||
placeholder=move || display_label.get()
|
||||
prop:value=move || {
|
||||
if is_open.get() {
|
||||
search_text.get()
|
||||
} else {
|
||||
display_label.get()
|
||||
}
|
||||
}
|
||||
on:focus=move |_| {
|
||||
set_is_open.set(true);
|
||||
set_search_text.set(String::new());
|
||||
}
|
||||
on:blur=move |_| {
|
||||
// Delay to allow click events on options
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_timers::callback::Timeout;
|
||||
Timeout::new(150, move || {
|
||||
set_is_open.set(false);
|
||||
set_search_text.set(String::new());
|
||||
set_focused_index.set(None);
|
||||
}).forget();
|
||||
}
|
||||
}
|
||||
on:input=move |ev| {
|
||||
set_search_text.set(event_target_value(&ev));
|
||||
set_is_open.set(true);
|
||||
set_focused_index.set(if filtered_count.get() > 0 { Some(0) } else { None });
|
||||
}
|
||||
on:keydown=move |ev| {
|
||||
let key = ev.key();
|
||||
match key.as_str() {
|
||||
"ArrowDown" => {
|
||||
ev.prevent_default();
|
||||
let count = filtered_count.get();
|
||||
if count > 0 {
|
||||
set_is_open.set(true);
|
||||
set_focused_index.update(|idx| {
|
||||
*idx = Some(idx.map(|i| (i + 1) % count).unwrap_or(0));
|
||||
});
|
||||
}
|
||||
}
|
||||
"ArrowUp" => {
|
||||
ev.prevent_default();
|
||||
let count = filtered_count.get();
|
||||
if count > 0 {
|
||||
set_focused_index.update(|idx| {
|
||||
*idx = Some(idx.map(|i| if i == 0 { count - 1 } else { i - 1 }).unwrap_or(count - 1));
|
||||
});
|
||||
}
|
||||
}
|
||||
"Enter" => {
|
||||
ev.prevent_default();
|
||||
if let Some(idx) = focused_index.get() {
|
||||
if let Some((val, _)) = get_option_at_index(idx) {
|
||||
handle_select(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
"Escape" => {
|
||||
set_is_open.set(false);
|
||||
set_search_text.set(String::new());
|
||||
set_focused_index.set(None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/>
|
||||
// Dropdown arrow indicator
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Dropdown listbox
|
||||
<Show when=move || is_open.get()>
|
||||
<ul
|
||||
id=listbox_id.clone()
|
||||
role="listbox"
|
||||
class="absolute z-50 w-full mt-1 max-h-60 overflow-auto bg-gray-800 border border-gray-600 rounded-lg shadow-lg"
|
||||
>
|
||||
{move || {
|
||||
let groups = filtered_groups.get();
|
||||
if groups.is_empty() {
|
||||
view! {
|
||||
<li class="px-3 py-2 text-gray-400 text-sm">"No matching platforms"</li>
|
||||
}.into_any()
|
||||
} else {
|
||||
let mut flat_index = 0usize;
|
||||
groups
|
||||
.into_iter()
|
||||
.map(|(group_label, options)| {
|
||||
let group_options = options
|
||||
.into_iter()
|
||||
.map(|(val, display)| {
|
||||
let current_index = flat_index;
|
||||
flat_index += 1;
|
||||
let is_selected = Signal::derive(move || value.get() == val);
|
||||
let is_focused = Signal::derive(move || focused_index.get() == Some(current_index));
|
||||
let val_string = val.to_string();
|
||||
view! {
|
||||
<li
|
||||
role="option"
|
||||
aria-selected=move || is_selected.get()
|
||||
class=move || {
|
||||
let mut classes = "px-3 py-2 cursor-pointer text-sm".to_string();
|
||||
if is_focused.get() {
|
||||
classes.push_str(" bg-blue-600 text-white");
|
||||
} else if is_selected.get() {
|
||||
classes.push_str(" bg-gray-700 text-blue-400");
|
||||
} else {
|
||||
classes.push_str(" text-gray-200 hover:bg-gray-700");
|
||||
}
|
||||
classes
|
||||
}
|
||||
on:mousedown=move |ev| {
|
||||
ev.prevent_default();
|
||||
handle_select(&val_string);
|
||||
}
|
||||
on:mouseenter=move |_| {
|
||||
set_focused_index.set(Some(current_index));
|
||||
}
|
||||
>
|
||||
{display}
|
||||
</li>
|
||||
}
|
||||
})
|
||||
.collect_view();
|
||||
|
||||
view! {
|
||||
<li role="group">
|
||||
<div class="px-3 py-1 text-xs font-semibold text-gray-400 bg-gray-900 sticky top-0">
|
||||
{group_label}
|
||||
</div>
|
||||
<ul role="group">
|
||||
{group_options}
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
.into_any()
|
||||
}
|
||||
}}
|
||||
</ul>
|
||||
</Show>
|
||||
|
||||
{if has_help {
|
||||
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Date input field with label.
|
||||
#[component]
|
||||
pub fn DateInput(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
#[prop(optional)] help_text: &'static str,
|
||||
#[prop(default = false)] required: bool,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_change: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
let help_id = format!("{}-help", name);
|
||||
let has_help = !help_text.is_empty();
|
||||
|
||||
view! {
|
||||
<div class=format!("space-y-2 {}", class)>
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{if required {
|
||||
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
|
||||
}}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id=input_id
|
||||
name=name
|
||||
required=required
|
||||
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
||||
class="input-base"
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_change.run(event_target_value(&ev))
|
||||
/>
|
||||
{if has_help {
|
||||
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ use leptos::prelude::*;
|
|||
use leptos::reactive::owner::LocalStorage;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::{InventoryItem, PropAcquisitionInfo, StateScope, StateVisibility};
|
||||
use chattyness_db::models::{InventoryItem, PropAcquisitionInfo};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
use leptos::ev::Event;
|
||||
|
||||
use super::modals::{ConfirmModal, GuestLockedOverlay, Modal};
|
||||
use super::tabs::{Tab, TabBar};
|
||||
|
|
@ -35,6 +34,9 @@ pub fn InventoryPopup(
|
|||
/// Whether the current user is a guest. Guests see a locked overlay.
|
||||
#[prop(optional, into)]
|
||||
is_guest: Option<Signal<bool>>,
|
||||
/// Callback when user wants to view an inventory item's state (e.g., business card info)
|
||||
#[prop(optional, into)]
|
||||
on_view_item_state: Option<Callback<Uuid>>,
|
||||
) -> impl IntoView {
|
||||
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
||||
// Tab state
|
||||
|
|
@ -291,6 +293,32 @@ pub fn InventoryPopup(
|
|||
#[cfg(not(feature = "hydrate"))]
|
||||
let handle_drop = |_item_id: Uuid| {};
|
||||
|
||||
// Handle copy and drop action via WebSocket (keeps original in inventory)
|
||||
#[cfg(feature = "hydrate")]
|
||||
let handle_copy_drop = {
|
||||
move |item_id: Uuid| {
|
||||
set_dropping.set(true);
|
||||
|
||||
// Send copy and drop command via WebSocket
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::CopyAndDropProp {
|
||||
inventory_item_id: item_id,
|
||||
});
|
||||
// Note: We don't remove from inventory since this is a copy
|
||||
// The original item stays in inventory
|
||||
} else {
|
||||
set_error.set(Some("Not connected to server".to_string()));
|
||||
}
|
||||
});
|
||||
|
||||
set_dropping.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let handle_copy_drop = |_item_id: Uuid| {};
|
||||
|
||||
// Handle delete action via WebSocket (permanent deletion)
|
||||
#[cfg(feature = "hydrate")]
|
||||
let handle_delete = {
|
||||
|
|
@ -368,11 +396,13 @@ pub fn InventoryPopup(
|
|||
set_selected_item=set_selected_item
|
||||
dropping=dropping
|
||||
on_drop=Callback::new(handle_drop)
|
||||
on_copy_drop=Callback::new(handle_copy_drop)
|
||||
deleting=deleting
|
||||
on_delete_request=Callback::new(move |(id, name)| {
|
||||
set_delete_confirm_item.set(Some((id, name)));
|
||||
})
|
||||
on_delete_immediate=Callback::new(handle_delete)
|
||||
on_view_item_state=on_view_item_state.clone().unwrap_or_else(|| Callback::new(|_| {}))
|
||||
ws_sender=ws_sender
|
||||
/>
|
||||
</Show>
|
||||
|
|
@ -455,10 +485,14 @@ fn MyInventoryTab(
|
|||
set_selected_item: WriteSignal<Option<Uuid>>,
|
||||
#[prop(into)] dropping: Signal<bool>,
|
||||
#[prop(into)] on_drop: Callback<Uuid>,
|
||||
/// Callback for copy and drop (creates copy on scene, keeps original in inventory)
|
||||
#[prop(into)] on_copy_drop: Callback<Uuid>,
|
||||
#[prop(into)] deleting: Signal<bool>,
|
||||
#[prop(into)] on_delete_request: Callback<(Uuid, String)>,
|
||||
/// Callback for immediate delete (Shift+Delete, no confirmation)
|
||||
#[prop(into)] on_delete_immediate: Callback<Uuid>,
|
||||
/// Callback when user wants to view item state (e.g., business card info)
|
||||
#[prop(into)] on_view_item_state: Callback<Uuid>,
|
||||
/// WebSocket sender for updating item state
|
||||
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
|
||||
) -> impl IntoView {
|
||||
|
|
@ -498,27 +532,42 @@ fn MyInventoryTab(
|
|||
let Some(item_id) = selected_item.get() else { return };
|
||||
let Some(item) = items.get().into_iter().find(|i| i.id == item_id) else { return };
|
||||
|
||||
// Only allow actions on droppable items
|
||||
if !item.is_droppable {
|
||||
return;
|
||||
}
|
||||
|
||||
let key = ev.key();
|
||||
let shift = ev.shift_key();
|
||||
|
||||
match key.as_str() {
|
||||
"i" | "I" => {
|
||||
// View Info: only if item has server_private_state
|
||||
if !item.server_private_state.is_null() {
|
||||
ev.prevent_default();
|
||||
on_view_item_state.run(item_id);
|
||||
}
|
||||
}
|
||||
"d" | "D" => {
|
||||
ev.prevent_default();
|
||||
on_drop.run(item_id);
|
||||
// Drop: only for droppable items
|
||||
if item.is_droppable {
|
||||
ev.prevent_default();
|
||||
on_drop.run(item_id);
|
||||
}
|
||||
}
|
||||
"c" | "C" => {
|
||||
// Copy & Drop: only for droppable, non-unique items
|
||||
if item.is_droppable && !item.is_unique {
|
||||
ev.prevent_default();
|
||||
on_copy_drop.run(item_id);
|
||||
}
|
||||
}
|
||||
"Delete" => {
|
||||
ev.prevent_default();
|
||||
if shift {
|
||||
// Shift+Delete: immediate delete without confirmation
|
||||
on_delete_immediate.run(item_id);
|
||||
} else {
|
||||
// Delete: delete with confirmation
|
||||
on_delete_request.run((item_id, item.prop_name.clone()));
|
||||
// Delete: only for droppable items
|
||||
if item.is_droppable {
|
||||
ev.prevent_default();
|
||||
if shift {
|
||||
// Shift+Delete: immediate delete without confirmation
|
||||
on_delete_immediate.run(item_id);
|
||||
} else {
|
||||
// Delete: delete with confirmation
|
||||
on_delete_request.run((item_id, item.prop_name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -614,11 +663,16 @@ fn MyInventoryTab(
|
|||
let item_id = selected_item.get()?;
|
||||
let item = items.get().into_iter().find(|i| i.id == item_id)?;
|
||||
let on_drop = on_drop.clone();
|
||||
let on_copy_drop = on_copy_drop.clone();
|
||||
let on_delete_request = on_delete_request.clone();
|
||||
let on_view_item_state = on_view_item_state.clone();
|
||||
let is_dropping = dropping.get();
|
||||
let is_deleting = deleting.get();
|
||||
let is_droppable = item.is_droppable;
|
||||
let is_unique = item.is_unique;
|
||||
let can_copy_drop = is_droppable && !is_unique;
|
||||
let item_name = item.prop_name.clone();
|
||||
let has_state = !item.server_private_state.is_null();
|
||||
|
||||
Some(view! {
|
||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
||||
|
|
@ -629,9 +683,33 @@ fn MyInventoryTab(
|
|||
{if item.is_transferable { "Transferable" } else { "Not transferable" }}
|
||||
{if item.is_portable { " \u{2022} Portable" } else { "" }}
|
||||
{if is_droppable { " \u{2022} Droppable" } else { " \u{2022} Essential" }}
|
||||
{if is_unique { " \u{2022} Unique" } else { "" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
// View Info button - only shown for items with server private state
|
||||
<Show when=move || has_state>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||
on:click=move |_| on_view_item_state.run(item_id)
|
||||
title="View item details (I)"
|
||||
>
|
||||
<span>"View "<u>"I"</u>"nfo"</span>
|
||||
</button>
|
||||
</Show>
|
||||
// Copy & Drop button - only shown for droppable, non-unique props
|
||||
<Show when=move || can_copy_drop>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
on:click=move |_| on_copy_drop.run(item_id)
|
||||
disabled=is_dropping
|
||||
title="Drop a copy, keep original (C)"
|
||||
>
|
||||
<span><u>"C"</u>"opy & Drop"</span>
|
||||
</button>
|
||||
</Show>
|
||||
// Drop button - disabled for non-droppable (essential) props
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -646,7 +724,7 @@ fn MyInventoryTab(
|
|||
}
|
||||
}
|
||||
disabled=is_dropping || !is_droppable
|
||||
title=if is_droppable { "Drop prop to scene canvas" } else { "Essential prop cannot be dropped" }
|
||||
title=if is_droppable { "Drop prop to scene canvas (removes from inventory)" } else { "Essential prop cannot be dropped" }
|
||||
>
|
||||
{if is_dropping {
|
||||
view! { "Dropping..." }.into_any()
|
||||
|
|
@ -682,21 +760,6 @@ fn MyInventoryTab(
|
|||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Show BusinessCardEditor for business card props
|
||||
{
|
||||
let is_business_card = item.prop_name.to_lowercase().contains("businesscard");
|
||||
if is_business_card {
|
||||
Some(view! {
|
||||
<BusinessCardEditor
|
||||
item=item.clone()
|
||||
ws_sender=ws_sender
|
||||
/>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
|
|
@ -706,164 +769,6 @@ fn MyInventoryTab(
|
|||
}
|
||||
}
|
||||
|
||||
/// Helper function to get string value from Event target.
|
||||
fn event_target_value(ev: &Event) -> String {
|
||||
use leptos::wasm_bindgen::JsCast;
|
||||
ev.target()
|
||||
.and_then(|t| t.dyn_into::<leptos::web_sys::HtmlInputElement>().ok())
|
||||
.map(|input| input.value())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Business card editor component for customizing business card props.
|
||||
///
|
||||
/// Allows editing profile information (name, title, social links) that will
|
||||
/// be displayed when other users view the prop.
|
||||
#[component]
|
||||
fn BusinessCardEditor(
|
||||
item: InventoryItem,
|
||||
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
|
||||
) -> impl IntoView {
|
||||
// Extract existing profile data from server_state
|
||||
let profile = item
|
||||
.server_state
|
||||
.get("profile")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let (name, set_name) = signal(
|
||||
profile
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (title, set_title) = signal(
|
||||
profile
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (linkedin, set_linkedin) = signal(
|
||||
profile
|
||||
.get("linkedin")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (github, set_github) = signal(
|
||||
profile
|
||||
.get("github")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (website, set_website) = signal(
|
||||
profile
|
||||
.get("website")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
let (saving, set_saving) = signal(false);
|
||||
let (save_message, set_save_message) = signal(Option::<(bool, String)>::None);
|
||||
|
||||
let item_id = item.id;
|
||||
|
||||
let handle_save = move |_| {
|
||||
set_saving.set(true);
|
||||
set_save_message.set(None);
|
||||
|
||||
let state = serde_json::json!({
|
||||
"profile": {
|
||||
"name": name.get(),
|
||||
"title": title.get(),
|
||||
"linkedin": linkedin.get(),
|
||||
"github": github.get(),
|
||||
"website": website.get()
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::UpdateItemState {
|
||||
inventory_item_id: item_id,
|
||||
scope: StateScope::Server,
|
||||
visibility: StateVisibility::Public,
|
||||
state,
|
||||
merge: false, // Replace entire server_state
|
||||
});
|
||||
set_save_message.set(Some((true, "Saved!".to_string())));
|
||||
} else {
|
||||
set_save_message.set(Some((false, "Not connected".to_string())));
|
||||
}
|
||||
});
|
||||
|
||||
set_saving.set(false);
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
||||
<h4 class="text-white font-medium mb-3">"Business Card Details"</h4>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your Name"
|
||||
prop:value=move || name.get()
|
||||
on:input=move |e| set_name.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title / Position"
|
||||
prop:value=move || title.get()
|
||||
on:input=move |e| set_title.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="LinkedIn username"
|
||||
prop:value=move || linkedin.get()
|
||||
on:input=move |e| set_linkedin.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="GitHub username"
|
||||
prop:value=move || github.get()
|
||||
on:input=move |e| set_github.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Website URL"
|
||||
prop:value=move || website.get()
|
||||
on:input=move |e| set_website.set(event_target_value(&e))
|
||||
class="w-full px-3 py-2 bg-gray-700 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50"
|
||||
on:click=handle_save
|
||||
disabled=move || saving.get()
|
||||
>
|
||||
{move || if saving.get() { "Saving..." } else { "Save Business Card" }}
|
||||
</button>
|
||||
{move || save_message.get().map(|(success, msg)| {
|
||||
let class = if success {
|
||||
"text-green-400 text-sm mt-2"
|
||||
} else {
|
||||
"text-red-400 text-sm mt-2"
|
||||
};
|
||||
view! { <p class=class>{msg}</p> }
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquisition props tab content with acquire functionality.
|
||||
#[component]
|
||||
fn AcquisitionPropsTab(
|
||||
|
|
|
|||
|
|
@ -430,8 +430,15 @@ pub fn PropInfoModal(
|
|||
// Content based on action hint
|
||||
{match state.action_hint {
|
||||
Some(PropActionHint::BusinessCard) => {
|
||||
let server_private_state = state.private_state
|
||||
.as_ref()
|
||||
.map(|ps| ps.server_private_state.clone())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
view! {
|
||||
<BusinessCardView server_state=state.server_state />
|
||||
<BusinessCardView
|
||||
server_state=state.server_state
|
||||
server_private_state=server_private_state
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
Some(PropActionHint::ExternalLinks) => {
|
||||
|
|
@ -456,21 +463,62 @@ pub fn PropInfoModal(
|
|||
|
||||
/// Business card view for props with profile information.
|
||||
///
|
||||
/// Displays profile fields and social media link buttons.
|
||||
/// Displays profile fields from `server_private_state.snapshot` (captured giver profile).
|
||||
/// For received cards, shows a "View Profile" button linking to the giver's profile.
|
||||
#[component]
|
||||
pub fn BusinessCardView(
|
||||
server_state: serde_json::Value,
|
||||
server_private_state: serde_json::Value,
|
||||
) -> impl IntoView {
|
||||
// Extract profile from server_state
|
||||
let profile = server_state.get("profile").cloned().unwrap_or(serde_json::Value::Null);
|
||||
// Check if this is the owner's card (acquired from library, not received from someone)
|
||||
let is_owner_card = server_state.get("is_owner_card")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let name = profile.get("name").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let title = profile.get("title").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let company = profile.get("company").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let linkedin = profile.get("linkedin").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let github = profile.get("github").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let twitter = profile.get("twitter").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let website = profile.get("website").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
// If this is the owner's card (not received from someone), show a message
|
||||
if is_owner_card {
|
||||
return view! {
|
||||
<div class="text-center py-4">
|
||||
<p class="text-gray-400">
|
||||
"This is your business card. Drop it in a scene for others to pick up."
|
||||
</p>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
"Recipients will see your profile information."
|
||||
</p>
|
||||
</div>
|
||||
}.into_any();
|
||||
}
|
||||
|
||||
// Get snapshot from server_private_state (received cards)
|
||||
let Some(snapshot) = server_private_state.get("snapshot").cloned() else {
|
||||
// No snapshot means loose prop on scene - show pickup message
|
||||
return view! {
|
||||
<div class="text-center py-4">
|
||||
<p class="text-gray-400">
|
||||
"Pick up this business card to see the owner's profile information."
|
||||
</p>
|
||||
</div>
|
||||
}.into_any();
|
||||
};
|
||||
|
||||
// Extract profile data from snapshot
|
||||
let name = snapshot.get("display_name").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
// Combine first and last name if display_name not set
|
||||
let first = snapshot.get("name_first").and_then(|v| v.as_str());
|
||||
let last = snapshot.get("name_last").and_then(|v| v.as_str());
|
||||
match (first, last) {
|
||||
(Some(f), Some(l)) => Some(format!("{} {}", f, l)),
|
||||
(Some(f), None) => Some(f.to_string()),
|
||||
(None, Some(l)) => Some(l.to_string()),
|
||||
(None, None) => None,
|
||||
}
|
||||
});
|
||||
let username = snapshot.get("username").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let summary = snapshot.get("summary").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let homepage = snapshot.get("homepage").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let email = snapshot.get("email").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let phone = snapshot.get("phone").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
|
||||
view! {
|
||||
<div class="space-y-4">
|
||||
|
|
@ -479,85 +527,65 @@ pub fn BusinessCardView(
|
|||
{name.clone().map(|n| view! {
|
||||
<h3 class="text-2xl font-bold text-white">{n}</h3>
|
||||
})}
|
||||
{title.clone().map(|t| view! {
|
||||
<p class="text-gray-300">{t}</p>
|
||||
})}
|
||||
{company.clone().map(|c| view! {
|
||||
<p class="text-gray-400 text-sm">{c}</p>
|
||||
{summary.clone().map(|s| view! {
|
||||
<p class="text-gray-400 text-sm mt-2 italic">"\""{ s }"\""</p>
|
||||
})}
|
||||
</div>
|
||||
|
||||
// Social links
|
||||
<div class="flex flex-wrap justify-center gap-2 pt-4 border-t border-gray-700">
|
||||
{linkedin.clone().map(|username| {
|
||||
let url = format!("https://linkedin.com/in/{}", username);
|
||||
view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-700 hover:bg-blue-600 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
"LinkedIn"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
// Contact info
|
||||
{(email.is_some() || phone.is_some() || homepage.is_some()).then(|| view! {
|
||||
<div class="space-y-1 text-center text-sm">
|
||||
{email.clone().map(|e| {
|
||||
let mailto = format!("mailto:{}", e);
|
||||
view! {
|
||||
<p class="text-gray-300">
|
||||
<span class="text-gray-500">"Email: "</span>
|
||||
<a href=mailto class="text-blue-400 hover:underline">{e}</a>
|
||||
</p>
|
||||
}
|
||||
})}
|
||||
{phone.clone().map(|p| {
|
||||
let tel = format!("tel:{}", p);
|
||||
view! {
|
||||
<p class="text-gray-300">
|
||||
<span class="text-gray-500">"Phone: "</span>
|
||||
<a href=tel class="text-blue-400 hover:underline">{p}</a>
|
||||
</p>
|
||||
}
|
||||
})}
|
||||
{homepage.clone().map(|url| {
|
||||
let href = url.clone();
|
||||
view! {
|
||||
<p class="text-gray-300">
|
||||
<span class="text-gray-500">"Website: "</span>
|
||||
<a href=href target="_blank" rel="noopener noreferrer" class="text-blue-400 hover:underline">{url}</a>
|
||||
</p>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{github.clone().map(|username| {
|
||||
let url = format!("https://github.com/{}", username);
|
||||
view! {
|
||||
// View Profile button (if we have username from snapshot)
|
||||
{username.clone().map(|u| {
|
||||
let profile_url = format!("/users/{}", u);
|
||||
view! {
|
||||
<div class="flex justify-center pt-4 border-t border-gray-700">
|
||||
<a
|
||||
href=url
|
||||
href=profile_url
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
"GitHub"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
|
||||
{twitter.clone().map(|username| {
|
||||
let url = format!("https://twitter.com/{}", username);
|
||||
view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-sky-600 hover:bg-sky-500 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"/>
|
||||
</svg>
|
||||
"Twitter"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
|
||||
{website.clone().map(|url| {
|
||||
view! {
|
||||
<a
|
||||
href=url.clone()
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-white text-sm transition-colors"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
"Website"
|
||||
"View Full Profile"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
/// External links view for props with link arrays.
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ pub fn RealmSceneViewer(
|
|||
let (context_menu_open, set_context_menu_open) = signal(false);
|
||||
let (context_menu_position, set_context_menu_position) = signal((0.0_f64, 0.0_f64));
|
||||
let (context_menu_target, set_context_menu_target) = signal(Option::<String>::None);
|
||||
let (context_menu_username, set_context_menu_username) = signal(Option::<String>::None);
|
||||
|
||||
// Prop context menu state
|
||||
let (prop_context_menu_open, set_prop_context_menu_open) = signal(false);
|
||||
|
|
@ -258,13 +259,13 @@ pub fn RealmSceneViewer(
|
|||
if hit_test_canvas(&canvas, client_x, client_y) {
|
||||
if let Ok(member_id) = member_id_str.parse::<Uuid>() {
|
||||
if my_user_id != Some(member_id) {
|
||||
if let Some(name) = members.get().iter()
|
||||
if let Some(member) = members.get().iter()
|
||||
.find(|m| m.member.user_id == member_id)
|
||||
.map(|m| m.member.display_name.clone())
|
||||
{
|
||||
ev.prevent_default();
|
||||
set_context_menu_position.set((client_x, client_y));
|
||||
set_context_menu_target.set(Some(name));
|
||||
set_context_menu_target.set(Some(member.member.display_name.clone()));
|
||||
set_context_menu_username.set(Some(member.member.username.clone()));
|
||||
set_context_menu_open.set(true);
|
||||
return;
|
||||
}
|
||||
|
|
@ -581,22 +582,40 @@ pub fn RealmSceneViewer(
|
|||
open=Signal::derive(move || context_menu_open.get())
|
||||
position=Signal::derive(move || context_menu_position.get())
|
||||
header=Signal::derive(move || context_menu_target.get())
|
||||
items=Signal::derive(move || vec![ContextMenuItem { label: "Whisper".to_string(), action: "whisper".to_string() }])
|
||||
items=Signal::derive(move || vec![
|
||||
ContextMenuItem { label: "View Profile".to_string(), action: "view_profile".to_string() },
|
||||
ContextMenuItem { label: "Whisper".to_string(), action: "whisper".to_string() },
|
||||
])
|
||||
on_select=Callback::new({
|
||||
let on_whisper_request = on_whisper_request.clone();
|
||||
move |action: String| {
|
||||
if action == "whisper" {
|
||||
if let Some(target) = context_menu_target.get() {
|
||||
if let Some(ref callback) = on_whisper_request {
|
||||
callback.run(target);
|
||||
match action.as_str() {
|
||||
"view_profile" => {
|
||||
if let Some(username) = context_menu_username.get() {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
let url = format!("/users/{}", username);
|
||||
let _ = web_sys::window()
|
||||
.unwrap()
|
||||
.open_with_url_and_target(&url, "_blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
"whisper" => {
|
||||
if let Some(target) = context_menu_target.get() {
|
||||
if let Some(ref callback) = on_whisper_request {
|
||||
callback.run(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
on_close=Callback::new(move |_: ()| {
|
||||
set_context_menu_open.set(false);
|
||||
set_context_menu_target.set(None);
|
||||
set_context_menu_username.set(None);
|
||||
})
|
||||
/>
|
||||
<ContextMenu
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ pub struct ModCommandResultInfo {
|
|||
pub struct MemberIdentityInfo {
|
||||
/// User ID of the member.
|
||||
pub user_id: uuid::Uuid,
|
||||
/// New username (for profile URLs).
|
||||
pub username: String,
|
||||
/// New display name.
|
||||
pub display_name: String,
|
||||
/// Whether the member is still a guest.
|
||||
|
|
@ -715,6 +717,7 @@ fn handle_server_message(
|
|||
}
|
||||
ServerMessage::MemberIdentityUpdated {
|
||||
user_id,
|
||||
username,
|
||||
display_name,
|
||||
is_guest,
|
||||
} => {
|
||||
|
|
@ -723,6 +726,7 @@ fn handle_server_message(
|
|||
.iter_mut()
|
||||
.find(|m| m.member.user_id == user_id)
|
||||
{
|
||||
member.member.username = username.clone();
|
||||
member.member.display_name = display_name.clone();
|
||||
member.member.is_guest = is_guest;
|
||||
}
|
||||
|
|
@ -730,6 +734,7 @@ fn handle_server_message(
|
|||
state.members.clone(),
|
||||
MemberIdentityInfo {
|
||||
user_id,
|
||||
username,
|
||||
display_name,
|
||||
is_guest,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@
|
|||
pub mod home;
|
||||
pub mod login;
|
||||
pub mod password_reset;
|
||||
pub mod profile;
|
||||
pub mod realm;
|
||||
pub mod signup;
|
||||
pub mod user_profile;
|
||||
|
||||
pub use home::*;
|
||||
pub use login::*;
|
||||
pub use password_reset::*;
|
||||
pub use profile::*;
|
||||
pub use realm::*;
|
||||
pub use signup::*;
|
||||
pub use user_profile::*;
|
||||
|
|
|
|||
1605
crates/chattyness-user-ui/src/pages/profile.rs
Normal file
1605
crates/chattyness-user-ui/src/pages/profile.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -470,13 +470,15 @@ pub fn RealmPage() -> impl IntoView {
|
|||
.forget();
|
||||
}
|
||||
WsEvent::MemberIdentityUpdated(info) => {
|
||||
// Update the member's display name in the members list
|
||||
// Update the member's identity in the members list
|
||||
set_members.update(|members| {
|
||||
if let Some(member) = members
|
||||
.iter_mut()
|
||||
.find(|m| m.member.user_id == info.user_id)
|
||||
{
|
||||
member.member.username = info.username.clone();
|
||||
member.member.display_name = info.display_name.clone();
|
||||
member.member.is_guest = info.is_guest;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -735,6 +737,22 @@ pub fn RealmPage() -> impl IntoView {
|
|||
#[cfg(not(feature = "hydrate"))]
|
||||
let on_view_prop_state = Callback::new(move |_prop_id: Uuid| {});
|
||||
|
||||
// Handle inventory item state view request (View Info) via WebSocket
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_view_inventory_item_state = Callback::new(move |item_id: Uuid| {
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::ViewPropState {
|
||||
prop_id: item_id,
|
||||
is_loose_prop: false, // inventory item, not loose prop
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let on_view_inventory_item_state = Callback::new(move |_item_id: Uuid| {});
|
||||
|
||||
// Handle global keyboard shortcuts (e+0-9 for emotions, : for chat focus)
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
|
|
@ -1208,6 +1226,16 @@ pub fn RealmPage() -> impl IntoView {
|
|||
let on_open_register_cb = Callback::new(move |_: ()| {
|
||||
set_register_modal_open.set(true);
|
||||
});
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate_for_profile = use_navigate();
|
||||
let slug_for_profile = slug.clone();
|
||||
let on_open_profile_cb = Callback::new(move |_: ()| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
let profile_url = format!("/profile?realm={}", slug_for_profile.get());
|
||||
let _ = navigate_for_profile(&profile_url, Default::default());
|
||||
}
|
||||
});
|
||||
let on_whisper_request_cb = Callback::new(move |target: String| {
|
||||
whisper_target.set(Some(target));
|
||||
});
|
||||
|
|
@ -1326,6 +1354,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
on_mod_command=on_mod_command_cb
|
||||
is_guest=Signal::derive(move || is_guest.get())
|
||||
on_open_register=on_open_register_cb
|
||||
on_open_profile=on_open_profile_cb
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1365,6 +1394,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
ws_sender=ws_sender_for_inv
|
||||
realm_slug=Signal::derive(move || slug.get())
|
||||
is_guest=Signal::derive(move || is_guest.get())
|
||||
on_view_item_state=on_view_inventory_item_state
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
339
crates/chattyness-user-ui/src/pages/user_profile.rs
Normal file
339
crates/chattyness-user-ui/src/pages/user_profile.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
//! Public user profile view page.
|
||||
//!
|
||||
//! Displays a user's public profile at /users/{username}.
|
||||
//! Respects visibility settings - hidden content is not shown.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, ErrorAlert, PageLayout};
|
||||
use chattyness_db::models::{PublicProfile, UserContact, UserOrganization};
|
||||
|
||||
/// Public user profile view page.
|
||||
#[component]
|
||||
pub fn UserProfilePage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let username = move || params.read().get("username").unwrap_or_default();
|
||||
|
||||
let (profile, set_profile) = signal(Option::<PublicProfile>::None);
|
||||
let (loading, set_loading) = signal(true);
|
||||
let (error, set_error) = signal(Option::<String>::None);
|
||||
|
||||
// Fetch profile on mount
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
Effect::new(move |_| {
|
||||
let username = username();
|
||||
if username.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
spawn_local(async move {
|
||||
let resp = Request::get(&format!("/api/users/{}", username))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_loading.set(false);
|
||||
|
||||
match resp {
|
||||
Ok(r) if r.ok() => {
|
||||
if let Ok(p) = r.json::<PublicProfile>().await {
|
||||
set_profile.set(Some(p));
|
||||
} else {
|
||||
set_error.set(Some("Failed to parse profile".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(r) if r.status() == 404 => {
|
||||
set_error.set(Some("User not found".to_string()));
|
||||
}
|
||||
Ok(_) => {
|
||||
set_error.set(Some("Failed to load profile".to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some("Network error".to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
view! {
|
||||
<PageLayout>
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<Show
|
||||
when=move || loading.get()
|
||||
fallback=move || {
|
||||
view! {
|
||||
<Show
|
||||
when=move || error.get().is_some()
|
||||
fallback=move || {
|
||||
view! {
|
||||
{move || {
|
||||
profile
|
||||
.get()
|
||||
.map(|p| {
|
||||
view! { <ProfileView profile=p /> }
|
||||
})
|
||||
}}
|
||||
}
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<ErrorAlert message=Signal::derive(move || error.get()) />
|
||||
</Card>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<div class="animate-pulse space-y-4">
|
||||
<div class="h-8 bg-gray-700 rounded w-1/3"></div>
|
||||
<div class="h-4 bg-gray-700 rounded w-2/3"></div>
|
||||
<div class="h-4 bg-gray-700 rounded w-1/2"></div>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile view component.
|
||||
#[component]
|
||||
fn ProfileView(profile: PublicProfile) -> impl IntoView {
|
||||
let display_name = profile.display_name.clone();
|
||||
let username = profile.username.clone();
|
||||
let email = profile.email.clone();
|
||||
let phone = profile.phone.clone();
|
||||
let summary = profile.summary.clone();
|
||||
let bio = profile.bio.clone();
|
||||
let homepage = profile.homepage.clone();
|
||||
let member_since = profile.member_since.format("%B %Y").to_string();
|
||||
let is_owner = profile.is_owner;
|
||||
let contacts = profile.contacts.clone();
|
||||
let organizations = profile.organizations.clone();
|
||||
|
||||
view! {
|
||||
<div class="space-y-6">
|
||||
// Header section
|
||||
<Card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
// Avatar placeholder
|
||||
<div class="w-20 h-20 rounded-full bg-gray-600 flex items-center justify-center text-3xl font-bold text-gray-300">
|
||||
{display_name.chars().next().unwrap_or('?').to_uppercase().to_string()}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">{display_name.clone()}</h1>
|
||||
<p class="text-gray-400">"@"{username.clone()}</p>
|
||||
{summary
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
let s = s.clone();
|
||||
view! { <p class="text-gray-300 mt-1">{s}</p> }
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{if is_owner {
|
||||
Some(
|
||||
view! {
|
||||
<a
|
||||
href="/profile"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
"Edit Profile"
|
||||
</a>
|
||||
},
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-700 flex flex-wrap gap-4 text-sm text-gray-400">
|
||||
<span>"Member since " {member_since}</span>
|
||||
{email
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
let href = format!("mailto:{}", e);
|
||||
let display = e.clone();
|
||||
view! {
|
||||
<a
|
||||
href=href
|
||||
class="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{display}
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
{phone
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
let href = format!("tel:{}", p);
|
||||
let display = p.clone();
|
||||
view! {
|
||||
<a
|
||||
href=href
|
||||
class="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{display}
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
{homepage
|
||||
.as_ref()
|
||||
.map(|url| {
|
||||
let url = url.clone();
|
||||
let display_url = url
|
||||
.replace("https://", "")
|
||||
.replace("http://", "")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{display_url}
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
// Bio section
|
||||
{bio
|
||||
.as_ref()
|
||||
.map(|b| {
|
||||
let b = b.clone();
|
||||
view! {
|
||||
<Card>
|
||||
<h2 class="text-lg font-semibold text-white mb-3">"About"</h2>
|
||||
<p class="text-gray-300 whitespace-pre-wrap">{b}</p>
|
||||
</Card>
|
||||
}
|
||||
})}
|
||||
|
||||
// Contacts section
|
||||
{contacts
|
||||
.as_ref()
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(|c| {
|
||||
let contacts = c.clone();
|
||||
view! { <ContactsView contacts=contacts /> }
|
||||
})}
|
||||
|
||||
// Organizations section
|
||||
{organizations
|
||||
.as_ref()
|
||||
.filter(|o| !o.is_empty())
|
||||
.map(|o| {
|
||||
let orgs = o.clone();
|
||||
view! { <OrganizationsView organizations=orgs /> }
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Contacts display component.
|
||||
#[component]
|
||||
fn ContactsView(contacts: Vec<UserContact>) -> impl IntoView {
|
||||
view! {
|
||||
<Card>
|
||||
<h2 class="text-lg font-semibold text-white mb-3">"Contacts"</h2>
|
||||
<ul class="space-y-2">
|
||||
{contacts
|
||||
.into_iter()
|
||||
.map(|contact| {
|
||||
let platform_label = contact.platform.label();
|
||||
let value = contact.value.clone();
|
||||
let label = contact.label.clone();
|
||||
view! {
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="text-blue-400 font-medium">{platform_label}</span>
|
||||
<span class="text-gray-300">{value}</span>
|
||||
{label
|
||||
.map(|l| {
|
||||
view! {
|
||||
<span class="text-gray-500">"(" {l} ")"</span>
|
||||
}
|
||||
})}
|
||||
</li>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()}
|
||||
</ul>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
/// Organizations display component.
|
||||
#[component]
|
||||
fn OrganizationsView(organizations: Vec<UserOrganization>) -> impl IntoView {
|
||||
view! {
|
||||
<Card>
|
||||
<h2 class="text-lg font-semibold text-white mb-3">"Organizations"</h2>
|
||||
<ul class="space-y-3">
|
||||
{organizations
|
||||
.into_iter()
|
||||
.map(|org| {
|
||||
let name = org.name.clone();
|
||||
let role = org.role.clone();
|
||||
let department = org.department.clone();
|
||||
let is_current = org.is_current;
|
||||
let dates = format_org_dates(&org);
|
||||
view! {
|
||||
<li class="border-l-2 border-gray-600 pl-3">
|
||||
<div class="font-medium text-white">
|
||||
{name}
|
||||
{if is_current {
|
||||
Some(
|
||||
view! {
|
||||
<span class="ml-2 px-2 py-0.5 text-xs bg-green-900 text-green-300 rounded">
|
||||
"Current"
|
||||
</span>
|
||||
},
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}}
|
||||
</div>
|
||||
{role
|
||||
.map(|r| {
|
||||
view! { <div class="text-gray-300">{r}</div> }
|
||||
})}
|
||||
{department
|
||||
.map(|d| {
|
||||
view! { <div class="text-gray-400 text-sm">{d}</div> }
|
||||
})}
|
||||
{dates
|
||||
.map(|d| {
|
||||
view! { <div class="text-gray-500 text-sm">{d}</div> }
|
||||
})}
|
||||
</li>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()}
|
||||
</ul>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
/// Format organization dates for display.
|
||||
fn format_org_dates(org: &UserOrganization) -> Option<String> {
|
||||
match (org.start_date, org.end_date, org.is_current) {
|
||||
(Some(start), None, true) => Some(format!("{} - Present", start.format("%b %Y"))),
|
||||
(Some(start), Some(end), false) => {
|
||||
Some(format!("{} - {}", start.format("%b %Y"), end.format("%b %Y")))
|
||||
}
|
||||
(Some(start), None, false) => Some(format!("{}", start.format("%b %Y"))),
|
||||
(None, Some(end), _) => Some(format!("Until {}", end.format("%b %Y"))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ use leptos_router::{
|
|||
components::{Route, Routes},
|
||||
};
|
||||
|
||||
use crate::pages::{HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage};
|
||||
use crate::pages::{HomePage, LoginPage, PasswordResetPage, ProfilePage, RealmPage, SignupPage, UserProfilePage};
|
||||
|
||||
/// User routes that can be embedded in a parent Router.
|
||||
///
|
||||
|
|
@ -29,7 +29,9 @@ pub fn UserRoutes() -> impl IntoView {
|
|||
<Route path=StaticSegment("") view=LoginPage />
|
||||
<Route path=StaticSegment("signup") view=SignupPage />
|
||||
<Route path=StaticSegment("home") view=HomePage />
|
||||
<Route path=StaticSegment("profile") view=ProfilePage />
|
||||
<Route path=StaticSegment("password-reset") view=PasswordResetPage />
|
||||
<Route path=(StaticSegment("users"), ParamSegment("username")) view=UserProfilePage />
|
||||
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage />
|
||||
<Route
|
||||
path=(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Add table
Add a link
Reference in a new issue