feat: add profile fields for local users
DB→domain→API→AP→frontend end-to-end. Fields stored as JSONB, exposed via PATCH /users/me, serialized as AP PropertyValue attachment. Editor in federation settings, display on profile card.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2554,6 +2554,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ tracing = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ impl<T> IntoAnyhow<T> for std::result::Result<T, sqlx::Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
use k_ap::{
|
use k_ap::{
|
||||||
ActivityRepository, ActorRepository, ApActorType, ApUser, ApUserRepository, BlockedDomain,
|
ActivityRepository, ActorRepository, ApActorType, ApProfileField, ApUser, ApUserRepository,
|
||||||
BlocklistRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
BlockedDomain, BlocklistRepository, FollowRepository, Follower, FollowerStatus,
|
||||||
|
FollowingStatus, RemoteActor,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── PostgresFederationRepository ─────────────────────────────────────────────
|
// ── PostgresFederationRepository ─────────────────────────────────────────────
|
||||||
@@ -751,6 +752,7 @@ struct UserRow {
|
|||||||
avatar_url: Option<String>,
|
avatar_url: Option<String>,
|
||||||
header_url: Option<String>,
|
header_url: Option<String>,
|
||||||
also_known_as: Option<String>,
|
also_known_as: Option<String>,
|
||||||
|
profile_fields: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PgApUserRepository {
|
pub struct PgApUserRepository {
|
||||||
@@ -767,6 +769,19 @@ impl PgApUserRepository {
|
|||||||
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, r.id)).ok();
|
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, r.id)).ok();
|
||||||
let avatar_url = r.avatar_url.and_then(|u| url::Url::parse(&u).ok());
|
let avatar_url = r.avatar_url.and_then(|u| url::Url::parse(&u).ok());
|
||||||
let banner_url = r.header_url.and_then(|u| url::Url::parse(&u).ok());
|
let banner_url = r.header_url.and_then(|u| url::Url::parse(&u).ok());
|
||||||
|
let attachment = r
|
||||||
|
.profile_fields
|
||||||
|
.and_then(|v| v.as_array().cloned())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.into_iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let name = item.get("name")?.as_str()?.to_string();
|
||||||
|
let value = item.get("value")?.as_str()?.to_string();
|
||||||
|
Some(ApProfileField { name, value })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
ApUser {
|
ApUser {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
username: r.username,
|
username: r.username,
|
||||||
@@ -776,7 +791,7 @@ impl PgApUserRepository {
|
|||||||
banner_url,
|
banner_url,
|
||||||
also_known_as: r.also_known_as.into_iter().collect(),
|
also_known_as: r.also_known_as.into_iter().collect(),
|
||||||
profile_url,
|
profile_url,
|
||||||
attachment: vec![],
|
attachment,
|
||||||
manually_approves_followers: true,
|
manually_approves_followers: true,
|
||||||
discoverable: true,
|
discoverable: true,
|
||||||
actor_type: ApActorType::default(),
|
actor_type: ApActorType::default(),
|
||||||
@@ -789,7 +804,7 @@ impl PgApUserRepository {
|
|||||||
impl ApUserRepository for PgApUserRepository {
|
impl ApUserRepository for PgApUserRepository {
|
||||||
async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
|
async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
|
||||||
let row = sqlx::query_as::<_, UserRow>(
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE id=$1 AND local=true",
|
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as,profile_fields FROM users WHERE id=$1 AND local=true",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -800,7 +815,7 @@ impl ApUserRepository for PgApUserRepository {
|
|||||||
|
|
||||||
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
|
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
|
||||||
let row = sqlx::query_as::<_, UserRow>(
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE username=$1 AND local=true",
|
"SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as,profile_fields FROM users WHERE username=$1 AND local=true",
|
||||||
)
|
)
|
||||||
.bind(username)
|
.bind(username)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
|||||||
avatar_url: r.avatar_url,
|
avatar_url: r.avatar_url,
|
||||||
header_url: r.header_url,
|
header_url: r.header_url,
|
||||||
custom_css: r.custom_css,
|
custom_css: r.custom_css,
|
||||||
|
profile_fields: vec![],
|
||||||
local: r.author_local,
|
local: r.author_local,
|
||||||
created_at: r.author_created_at,
|
created_at: r.author_created_at,
|
||||||
updated_at: r.author_updated_at,
|
updated_at: r.author_updated_at,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_fields JSONB DEFAULT '[]'::jsonb;
|
||||||
@@ -79,6 +79,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
|||||||
avatar_url: r.avatar_url,
|
avatar_url: r.avatar_url,
|
||||||
header_url: r.header_url,
|
header_url: r.header_url,
|
||||||
custom_css: r.custom_css,
|
custom_css: r.custom_css,
|
||||||
|
profile_fields: vec![],
|
||||||
local: r.author_local,
|
local: r.author_local,
|
||||||
created_at: r.author_created_at,
|
created_at: r.author_created_at,
|
||||||
updated_at: r.author_updated_at,
|
updated_at: r.author_updated_at,
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at
|
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at
|
||||||
FROM users u JOIN follows f ON f.follower_id=u.id
|
FROM users u JOIN follows f ON f.follower_id=u.id
|
||||||
WHERE f.following_id=$1 AND f.state='accepted'
|
WHERE f.following_id=$1 AND f.state='accepted'
|
||||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
@@ -154,7 +154,7 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at
|
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at
|
||||||
FROM users u JOIN follows f ON f.following_id=u.id
|
FROM users u JOIN follows f ON f.following_id=u.id
|
||||||
WHERE f.follower_id=$1 AND f.state='accepted'
|
WHERE f.follower_id=$1 AND f.state='accepted'
|
||||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
@@ -210,7 +210,7 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
|
|
||||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
"SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
"SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
||||||
u.avatar_url, u.header_url, u.custom_css, u.local,
|
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local,
|
||||||
u.created_at, u.updated_at
|
u.created_at, u.updated_at
|
||||||
FROM users u
|
FROM users u
|
||||||
JOIN follows f1
|
JOIN follows f1
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ impl TopFriendRepository for PgTopFriendRepository {
|
|||||||
let rows = sqlx::query_as::<_, TopFriendRow>(
|
let rows = sqlx::query_as::<_, TopFriendRow>(
|
||||||
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
||||||
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
||||||
u.avatar_url, u.header_url, u.custom_css, u.local,
|
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local,
|
||||||
u.created_at, u.updated_at
|
u.created_at, u.updated_at
|
||||||
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
||||||
WHERE tf.user_id=$1 ORDER BY tf.position",
|
WHERE tf.user_id=$1 ORDER BY tf.position",
|
||||||
|
|||||||
@@ -31,11 +31,26 @@ pub struct UserRow {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
|
pub profile_fields: Option<serde_json::Value>,
|
||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_profile_fields(v: Option<serde_json::Value>) -> Vec<(String, String)> {
|
||||||
|
v.and_then(|v| v.as_array().cloned())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.into_iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let name = item.get("name")?.as_str()?.to_string();
|
||||||
|
let value = item.get("value")?.as_str()?.to_string();
|
||||||
|
Some((name, value))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
impl From<UserRow> for User {
|
impl From<UserRow> for User {
|
||||||
fn from(r: UserRow) -> Self {
|
fn from(r: UserRow) -> Self {
|
||||||
User {
|
User {
|
||||||
@@ -48,6 +63,7 @@ impl From<UserRow> for User {
|
|||||||
avatar_url: r.avatar_url,
|
avatar_url: r.avatar_url,
|
||||||
header_url: r.header_url,
|
header_url: r.header_url,
|
||||||
custom_css: r.custom_css,
|
custom_css: r.custom_css,
|
||||||
|
profile_fields: parse_profile_fields(r.profile_fields),
|
||||||
local: r.local,
|
local: r.local,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
@@ -57,7 +73,7 @@ impl From<UserRow> for User {
|
|||||||
|
|
||||||
pub const USER_SELECT: &str =
|
pub const USER_SELECT: &str =
|
||||||
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
||||||
custom_css,local,created_at,updated_at FROM users";
|
custom_css,profile_fields,local,created_at,updated_at FROM users";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserReader for PgUserRepository {
|
impl UserReader for PgUserRepository {
|
||||||
@@ -222,14 +238,20 @@ impl UserReader for PgUserRepository {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserWriter for PgUserRepository {
|
impl UserWriter for PgUserRepository {
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
let profile_fields_json: serde_json::Value = user
|
||||||
|
.profile_fields
|
||||||
|
.iter()
|
||||||
|
.map(|(n, v)| serde_json::json!({"name": n, "value": v}))
|
||||||
|
.collect();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,created_at,updated_at)
|
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,local,created_at,updated_at)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
username=EXCLUDED.username, email=EXCLUDED.email,
|
username=EXCLUDED.username, email=EXCLUDED.email,
|
||||||
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
|
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
|
||||||
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
|
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
|
||||||
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
|
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
|
||||||
|
profile_fields=EXCLUDED.profile_fields,
|
||||||
local=EXCLUDED.local,
|
local=EXCLUDED.local,
|
||||||
updated_at=NOW()"
|
updated_at=NOW()"
|
||||||
)
|
)
|
||||||
@@ -242,6 +264,7 @@ impl UserWriter for PgUserRepository {
|
|||||||
.bind(&user.avatar_url)
|
.bind(&user.avatar_url)
|
||||||
.bind(&user.header_url)
|
.bind(&user.header_url)
|
||||||
.bind(&user.custom_css)
|
.bind(&user.custom_css)
|
||||||
|
.bind(&profile_fields_json)
|
||||||
.bind(user.local)
|
.bind(user.local)
|
||||||
.bind(user.created_at)
|
.bind(user.created_at)
|
||||||
.bind(user.updated_at)
|
.bind(user.updated_at)
|
||||||
@@ -267,6 +290,13 @@ impl UserWriter for PgUserRepository {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
input: UpdateProfileInput,
|
input: UpdateProfileInput,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
|
let profile_fields_json: Option<serde_json::Value> =
|
||||||
|
input.profile_fields.as_ref().map(|fields| {
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.map(|(n, v)| serde_json::json!({"name": n, "value": v}))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET \
|
"UPDATE users SET \
|
||||||
display_name = COALESCE($2, display_name), \
|
display_name = COALESCE($2, display_name), \
|
||||||
@@ -274,6 +304,7 @@ impl UserWriter for PgUserRepository {
|
|||||||
avatar_url = COALESCE($4, avatar_url), \
|
avatar_url = COALESCE($4, avatar_url), \
|
||||||
header_url = COALESCE($5, header_url), \
|
header_url = COALESCE($5, header_url), \
|
||||||
custom_css = COALESCE($6, custom_css), \
|
custom_css = COALESCE($6, custom_css), \
|
||||||
|
profile_fields = COALESCE($7, profile_fields), \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1",
|
WHERE id = $1",
|
||||||
)
|
)
|
||||||
@@ -283,6 +314,7 @@ impl UserWriter for PgUserRepository {
|
|||||||
.bind(input.avatar_url)
|
.bind(input.avatar_url)
|
||||||
.bind(input.header_url)
|
.bind(input.header_url)
|
||||||
.bind(input.custom_css)
|
.bind(input.custom_css)
|
||||||
|
.bind(profile_fields_json)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub struct UpdateProfileRequest {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
|
pub profile_fields: Option<Vec<crate::responses::ProfileField>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, utoipa::ToSchema)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Serialize, utoipa::ToSchema)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
@@ -19,6 +19,7 @@ pub struct UserResponse {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
|
pub profile_fields: Vec<ProfileField>,
|
||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub is_followed_by_viewer: bool,
|
pub is_followed_by_viewer: bool,
|
||||||
#[serde(rename = "joinedAt")]
|
#[serde(rename = "joinedAt")]
|
||||||
@@ -105,7 +106,7 @@ pub struct CreatedApiKeyResponse {
|
|||||||
pub key: String,
|
pub key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProfileField {
|
pub struct ProfileField {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ impl ActivityPubRepository for TestApRepo {
|
|||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
header_url: None,
|
header_url: None,
|
||||||
custom_css: None,
|
custom_css: None,
|
||||||
|
profile_fields: vec![],
|
||||||
local: false,
|
local: false,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
updated_at: chrono::Utc::now(),
|
updated_at: chrono::Utc::now(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct UpdateProfileInput {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
|
pub profile_fields: Option<Vec<(String, String)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -21,6 +22,7 @@ pub struct User {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
|
pub profile_fields: Vec<(String, String)>,
|
||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
@@ -44,6 +46,7 @@ impl User {
|
|||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
header_url: None,
|
header_url: None,
|
||||||
custom_css: None,
|
custom_css: None,
|
||||||
|
profile_fields: vec![],
|
||||||
local: true,
|
local: true,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::{deps_struct, errors::ApiError, extractors::Deps};
|
use crate::{deps_struct, errors::ApiError, extractors::Deps};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::{LoginRequest, RegisterRequest},
|
requests::{LoginRequest, RegisterRequest},
|
||||||
responses::{AuthResponse, ErrorResponse, UserResponse},
|
responses::{AuthResponse, ErrorResponse, ProfileField, UserResponse},
|
||||||
};
|
};
|
||||||
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
||||||
use axum::{http::StatusCode, response::IntoResponse, Json};
|
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||||
@@ -23,6 +23,14 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
|
|||||||
avatar_url: u.avatar_url.clone(),
|
avatar_url: u.avatar_url.clone(),
|
||||||
header_url: u.header_url.clone(),
|
header_url: u.header_url.clone(),
|
||||||
custom_css: u.custom_css.clone(),
|
custom_css: u.custom_css.clone(),
|
||||||
|
profile_fields: u
|
||||||
|
.profile_fields
|
||||||
|
.iter()
|
||||||
|
.map(|(n, v)| ProfileField {
|
||||||
|
name: n.clone(),
|
||||||
|
value: v.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
local: u.local,
|
local: u.local,
|
||||||
is_followed_by_viewer: false,
|
is_followed_by_viewer: false,
|
||||||
created_at: u.created_at,
|
created_at: u.created_at,
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ pub async fn patch_profile(
|
|||||||
avatar_url: body.avatar_url,
|
avatar_url: body.avatar_url,
|
||||||
header_url: body.header_url,
|
header_url: body.header_url,
|
||||||
custom_css: body.custom_css,
|
custom_css: body.custom_css,
|
||||||
|
profile_fields: body
|
||||||
|
.profile_fields
|
||||||
|
.map(|f| f.into_iter().map(|pf| (pf.name, pf.value)).collect()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -208,6 +211,7 @@ pub async fn get_users(
|
|||||||
avatar_url: u.avatar_url.clone(),
|
avatar_url: u.avatar_url.clone(),
|
||||||
header_url: None,
|
header_url: None,
|
||||||
custom_css: None,
|
custom_css: None,
|
||||||
|
profile_fields: vec![],
|
||||||
local: true,
|
local: true,
|
||||||
is_followed_by_viewer: false,
|
is_followed_by_viewer: false,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getMe } from "@/lib/api";
|
||||||
import { FederationPanel } from "@/components/federation/federation-panel";
|
import { FederationPanel } from "@/components/federation/federation-panel";
|
||||||
import { MigrationSettings } from "@/components/federation/migration-settings";
|
import { MigrationSettings } from "@/components/federation/migration-settings";
|
||||||
|
import { ProfileFieldsEditor } from "@/components/profile-fields-editor";
|
||||||
|
|
||||||
export default async function FederationSettingsPage() {
|
export default async function FederationSettingsPage() {
|
||||||
const token = (await cookies()).get("auth_token")?.value;
|
const token = (await cookies()).get("auth_token")?.value;
|
||||||
@@ -9,6 +11,8 @@ export default async function FederationSettingsPage() {
|
|||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const me = await getMe(token);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
|
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
|
||||||
@@ -18,6 +22,7 @@ export default async function FederationSettingsPage() {
|
|||||||
other instances.
|
other instances.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ProfileFieldsEditor initial={me.profileFields} />
|
||||||
<FederationPanel />
|
<FederationPanel />
|
||||||
<MigrationSettings />
|
<MigrationSettings />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -224,6 +224,27 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
{user.bio}
|
{user.bio}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{user.profileFields.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-0 text-sm">
|
||||||
|
{user.profileFields.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.name}
|
||||||
|
className="grid grid-cols-[minmax(0,5rem)_1fr] gap-2 border-t py-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-medium text-muted-foreground truncate"
|
||||||
|
title={field.name}
|
||||||
|
>
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
<span className="break-all min-w-0">
|
||||||
|
{field.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<div
|
<div
|
||||||
id="profile-card__stats"
|
id="profile-card__stats"
|
||||||
|
|||||||
99
thoughts-frontend/components/profile-fields-editor.tsx
Normal file
99
thoughts-frontend/components/profile-fields-editor.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { updateProfile, type ProfileField } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
const MAX_FIELDS = 4;
|
||||||
|
|
||||||
|
export function ProfileFieldsEditor({
|
||||||
|
initial,
|
||||||
|
}: {
|
||||||
|
initial: ProfileField[];
|
||||||
|
}) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [fields, setFields] = useState<ProfileField[]>(initial);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const update = (i: number, key: "name" | "value", val: string) => {
|
||||||
|
setFields((prev) => prev.map((f, j) => (j === i ? { ...f, [key]: val } : f)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
if (fields.length >= MAX_FIELDS) return;
|
||||||
|
setFields((prev) => [...prev, { name: "", value: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (i: number) => {
|
||||||
|
setFields((prev) => prev.filter((_, j) => j !== i));
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
const clean = fields.filter((f) => f.name.trim() || f.value.trim());
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateProfile({ profileFields: clean }, token);
|
||||||
|
setFields(clean);
|
||||||
|
toast.success("Profile fields saved.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save profile fields.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Profile fields</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add up to {MAX_FIELDS} custom fields visible on your profile and
|
||||||
|
across the fediverse (e.g. Website, Pronouns).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
value={f.name}
|
||||||
|
onChange={(e) => update(i, "name", e.target.value)}
|
||||||
|
placeholder="Label"
|
||||||
|
className="max-w-[8rem] text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={f.value}
|
||||||
|
onChange={(e) => update(i, "value", e.target.value)}
|
||||||
|
placeholder="Value"
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{fields.length < MAX_FIELDS && (
|
||||||
|
<Button variant="outline" size="sm" onClick={add}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add field
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={save} disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ProfileFieldSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
export type ProfileField = z.infer<typeof ProfileFieldSchema>;
|
||||||
|
|
||||||
export const UserSchema = z.object({
|
export const UserSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
@@ -9,6 +15,7 @@ export const UserSchema = z.object({
|
|||||||
avatarUrl: z.string().nullable(),
|
avatarUrl: z.string().nullable(),
|
||||||
headerUrl: z.string().nullable(),
|
headerUrl: z.string().nullable(),
|
||||||
customCss: z.string().nullable(),
|
customCss: z.string().nullable(),
|
||||||
|
profileFields: z.array(ProfileFieldSchema).default([]),
|
||||||
local: z.boolean(),
|
local: z.boolean(),
|
||||||
isFollowedByViewer: z.boolean(),
|
isFollowedByViewer: z.boolean(),
|
||||||
joinedAt: z.coerce.date().nullable(),
|
joinedAt: z.coerce.date().nullable(),
|
||||||
@@ -16,12 +23,6 @@ export const UserSchema = z.object({
|
|||||||
|
|
||||||
export const MeSchema = UserSchema;
|
export const MeSchema = UserSchema;
|
||||||
|
|
||||||
export const ProfileFieldSchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
});
|
|
||||||
export type ProfileField = z.infer<typeof ProfileFieldSchema>;
|
|
||||||
|
|
||||||
export const RemoteActorSchema = z.object({
|
export const RemoteActorSchema = z.object({
|
||||||
handle: z.string(),
|
handle: z.string(),
|
||||||
displayName: z.string().nullable(),
|
displayName: z.string().nullable(),
|
||||||
@@ -77,6 +78,7 @@ export const UpdateProfileSchema = z.object({
|
|||||||
displayName: z.string().max(50).optional(),
|
displayName: z.string().max(50).optional(),
|
||||||
bio: z.string().max(4000).optional(),
|
bio: z.string().max(4000).optional(),
|
||||||
customCss: z.string().optional(),
|
customCss: z.string().optional(),
|
||||||
|
profileFields: z.array(ProfileFieldSchema).max(4).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SearchResultsSchema = z.object({
|
export const SearchResultsSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user