feat(user): add Activity, Following, Stars, Security tabs to profile page
- Backend: user_activity service (user_activity_log + project_activity)
- Backend: stars service (repo_star + project_follow)
- Backend: user_get_following_list (with is_following_me mutual check)
- Frontend: Tab navigation on /user/{username} with Overview/Activity/Following/Stars/Security
- Frontend: UserActivity timeline, FollowingList grid, StarsList, SecurityTab (SSH keys + PATs)
This commit is contained in:
parent
f67c788cbe
commit
80e2201b8b
@ -432,6 +432,9 @@ use utoipa::OpenApi;
|
||||
crate::user::subscribe::get_subscribers,
|
||||
crate::user::subscribe::get_subscription_count,
|
||||
crate::user::subscribe::get_subscriber_count,
|
||||
crate::user::subscribe::get_following_list,
|
||||
crate::user::user_activity::get_user_activity,
|
||||
crate::user::stars::get_user_stars,
|
||||
crate::user::user_info::get_user_info,
|
||||
// Skill
|
||||
crate::skill::skill_list,
|
||||
@ -623,6 +626,12 @@ use utoipa::OpenApi;
|
||||
service::user::repository::UserReposResponse,
|
||||
service::user::repository::UserReposQuery,
|
||||
service::user::subscribe::SubscriptionInfo,
|
||||
service::user::subscribe::UserCard,
|
||||
service::user::user_activity::UserActivityItem,
|
||||
service::user::user_activity::UserActivityResponse,
|
||||
service::user::stars::RepoStarItem,
|
||||
service::user::stars::ProjectFollowItem,
|
||||
service::user::stars::UserStarsResponse,
|
||||
service::user::user_info::UserInfoExternal,
|
||||
// Workspace
|
||||
service::workspace::init::WorkspaceInitParams,
|
||||
|
||||
@ -6,7 +6,9 @@ pub mod profile;
|
||||
pub mod projects;
|
||||
pub mod repository;
|
||||
pub mod ssh_key;
|
||||
pub mod stars;
|
||||
pub mod subscribe;
|
||||
pub mod user_activity;
|
||||
pub mod user_info;
|
||||
|
||||
use actix_web::web;
|
||||
@ -86,6 +88,8 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) {
|
||||
web::get().to(chpc::get_contribution_heatmap),
|
||||
)
|
||||
.route("/{username}/keys", web::get().to(ssh_key::list_ssh_keys))
|
||||
.route("/{username}/activity", web::get().to(user_activity::get_user_activity))
|
||||
.route("/{username}/stars", web::get().to(stars::get_user_stars))
|
||||
.route(
|
||||
"/{username}/keys/{key_id}",
|
||||
web::get().to(ssh_key::get_ssh_key),
|
||||
@ -118,6 +122,10 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) {
|
||||
"/{username}/following/count",
|
||||
web::get().to(subscribe::get_subscription_count),
|
||||
)
|
||||
.route(
|
||||
"/{username}/following",
|
||||
web::get().to(subscribe::get_following_list),
|
||||
)
|
||||
.route(
|
||||
"/{username}/followers/count",
|
||||
web::get().to(subscribe::get_subscriber_count),
|
||||
|
||||
25
libs/api/user/stars.rs
Normal file
25
libs/api/user/stars.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use crate::{ApiResponse, error::ApiError};
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use service::AppService;
|
||||
use session::Session;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/users/{username}/stars",
|
||||
params(("username" = String, Path)),
|
||||
responses(
|
||||
(status = 200, description = "Get user stars", body = ApiResponse<service::user::stars::UserStarsResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
),
|
||||
tag = "User"
|
||||
)]
|
||||
pub async fn get_user_stars(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let username = path.into_inner();
|
||||
let resp = service.get_user_stars(session, username).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
@ -131,3 +131,24 @@ pub async fn get_subscriber_count(
|
||||
let resp = service.user_get_subscriber_count(session, username).await?;
|
||||
Ok(ApiResponse::ok(serde_json::json!({ "count": resp })).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/users/{username}/following",
|
||||
params(("username" = String, Path)),
|
||||
responses(
|
||||
(status = 200, description = "List following users", body = ApiResponse<Vec<service::user::subscribe::UserCard>>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
),
|
||||
tag = "User"
|
||||
)]
|
||||
pub async fn get_following_list(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let username = path.into_inner();
|
||||
let resp = service.user_get_following_list(session, username).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
33
libs/api/user/user_activity.rs
Normal file
33
libs/api/user/user_activity.rs
Normal file
@ -0,0 +1,33 @@
|
||||
use crate::{ApiResponse, error::ApiError};
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use service::AppService;
|
||||
use service::user::user_activity::UserActivityQuery;
|
||||
use session::Session;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/users/{username}/activity",
|
||||
params(
|
||||
("username" = String, Path),
|
||||
("page" = Option<u64>, Query),
|
||||
("per_page" = Option<u64>, Query),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Get user activity", body = ApiResponse<service::user::user_activity::UserActivityResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
),
|
||||
tag = "User"
|
||||
)]
|
||||
pub async fn get_user_activity(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
path: web::Path<String>,
|
||||
query: web::Query<UserActivityQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let username = path.into_inner();
|
||||
let resp = service
|
||||
.get_user_activity(session, username, query.into_inner())
|
||||
.await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
@ -8,5 +8,7 @@ pub mod profile;
|
||||
pub mod projects;
|
||||
pub mod repository;
|
||||
pub mod ssh_key;
|
||||
pub mod stars;
|
||||
pub mod subscribe;
|
||||
pub mod user_activity;
|
||||
pub mod user_info;
|
||||
|
||||
152
libs/service/user/stars.rs
Normal file
152
libs/service/user/stars.rs
Normal file
@ -0,0 +1,152 @@
|
||||
use crate::AppService;
|
||||
use crate::error::AppError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use models::projects::{project, project_follow};
|
||||
use models::repos::{repo, repo_star};
|
||||
use models::users::user;
|
||||
use sea_orm::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session::Session;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RepoStarItem {
|
||||
pub uid: Uuid,
|
||||
pub repo_name: String,
|
||||
pub owner: String,
|
||||
pub description: Option<String>,
|
||||
pub is_private: bool,
|
||||
pub default_branch: String,
|
||||
pub starred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProjectFollowItem {
|
||||
pub uid: Uuid,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub followed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserStarsResponse {
|
||||
pub repos: Vec<RepoStarItem>,
|
||||
pub projects: Vec<ProjectFollowItem>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl AppService {
|
||||
pub async fn get_user_stars(
|
||||
&self,
|
||||
context: Session,
|
||||
username: String,
|
||||
) -> Result<UserStarsResponse, AppError> {
|
||||
let target_user = user::Entity::find()
|
||||
.filter(user::Column::Username.eq(&username))
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
|
||||
let current_uid = context.user();
|
||||
let is_owner = current_uid
|
||||
.map(|uid| uid == target_user.uid)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Repo stars
|
||||
let stars = repo_star::Entity::find()
|
||||
.filter(repo_star::Column::User.eq(target_user.uid))
|
||||
.order_by_desc(repo_star::Column::CreatedAt)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
let repo_ids: Vec<Uuid> = stars.iter().map(|s| s.repo).collect();
|
||||
|
||||
let repos: std::collections::HashMap<Uuid, repo::Model> = repo::Entity::find()
|
||||
.filter(repo::Column::Id.is_in(repo_ids.clone()))
|
||||
.all(&self.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| (r.id, r))
|
||||
.collect();
|
||||
|
||||
let project_ids: Vec<Uuid> = repos.values().map(|r| r.project).collect();
|
||||
let projects_map: std::collections::HashMap<Uuid, project::Model> = project::Entity::find()
|
||||
.filter(project::Column::Id.is_in(project_ids))
|
||||
.all(&self.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p))
|
||||
.collect();
|
||||
|
||||
// Build repo items with privacy filter
|
||||
let mut repo_items: Vec<RepoStarItem> = Vec::new();
|
||||
for star in &stars {
|
||||
if let Some(r) = repos.get(&star.repo) {
|
||||
// Privacy: non-owners can only see public repos
|
||||
if !is_owner && r.is_private {
|
||||
continue;
|
||||
}
|
||||
let owner_username = if let Some(p) = projects_map.get(&r.project) {
|
||||
p.name.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
repo_items.push(RepoStarItem {
|
||||
uid: r.id,
|
||||
repo_name: r.repo_name.clone(),
|
||||
owner: owner_username,
|
||||
description: r.description.clone(),
|
||||
is_private: r.is_private,
|
||||
default_branch: r.default_branch.clone(),
|
||||
starred_at: star.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Project follows
|
||||
let follows = project_follow::Entity::find()
|
||||
.filter(project_follow::Column::User.eq(target_user.uid))
|
||||
.order_by_desc(project_follow::Column::CreatedAt)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
let followed_project_ids: Vec<Uuid> = follows.iter().map(|f| f.project).collect();
|
||||
|
||||
let followed_projects: std::collections::HashMap<Uuid, project::Model> = project::Entity::find()
|
||||
.filter(project::Column::Id.is_in(followed_project_ids))
|
||||
.all(&self.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p))
|
||||
.collect();
|
||||
|
||||
let mut project_items: Vec<ProjectFollowItem> = Vec::new();
|
||||
for follow in &follows {
|
||||
if let Some(p) = followed_projects.get(&follow.project) {
|
||||
// Privacy: non-owners can only see public projects
|
||||
if !is_owner && !p.is_public {
|
||||
continue;
|
||||
}
|
||||
project_items.push(ProjectFollowItem {
|
||||
uid: p.id,
|
||||
name: p.name.clone(),
|
||||
display_name: p.display_name.clone(),
|
||||
description: p.description.clone(),
|
||||
is_public: p.is_public,
|
||||
followed_at: follow.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let total = repo_items.len() as u64 + project_items.len() as u64;
|
||||
|
||||
Ok(UserStarsResponse {
|
||||
repos: repo_items,
|
||||
projects: project_items,
|
||||
total,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,15 @@ pub struct SubscriptionInfo {
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UserCard {
|
||||
pub user_uid: Uuid,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub is_following_me: bool,
|
||||
}
|
||||
|
||||
impl From<user_relation::Model> for SubscriptionInfo {
|
||||
fn from(sub: user_relation::Model) -> Self {
|
||||
SubscriptionInfo {
|
||||
@ -154,4 +163,63 @@ impl AppService {
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub async fn user_get_following_list(
|
||||
&self,
|
||||
context: Session,
|
||||
username: String,
|
||||
) -> Result<Vec<UserCard>, AppError> {
|
||||
let target_user = self.utils_find_user_by_username(username).await?;
|
||||
let target_uid = target_user.uid;
|
||||
let current_uid = context.user();
|
||||
|
||||
let following = user_relation::Entity::find()
|
||||
.filter(user_relation::Column::User.eq(target_uid))
|
||||
.filter(user_relation::Column::RelationType.eq("follow"))
|
||||
.order_by_desc(user_relation::Column::CreatedAt)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
let followed_uids: Vec<Uuid> = following.iter().map(|f| f.target).collect();
|
||||
|
||||
let followed_users: std::collections::HashMap<Uuid, models::users::user::Model> = models::users::user::Entity::find()
|
||||
.filter(models::users::user::Column::Uid.is_in(followed_uids.clone()))
|
||||
.all(&self.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| (u.uid, u))
|
||||
.collect();
|
||||
|
||||
// If current user is logged in, check who they also follow
|
||||
let current_follows: std::collections::HashSet<Uuid> = if let Some(uid) = current_uid {
|
||||
user_relation::Entity::find()
|
||||
.filter(user_relation::Column::User.eq(uid))
|
||||
.filter(user_relation::Column::Target.is_in(followed_uids.clone()))
|
||||
.filter(user_relation::Column::RelationType.eq("follow"))
|
||||
.select_only()
|
||||
.column(user_relation::Column::Target)
|
||||
.into_tuple::<Uuid>()
|
||||
.all(&self.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
std::collections::HashSet::new()
|
||||
};
|
||||
|
||||
let mut cards: Vec<UserCard> = Vec::new();
|
||||
for rel in following {
|
||||
if let Some(user) = followed_users.get(&rel.target) {
|
||||
cards.push(UserCard {
|
||||
user_uid: user.uid,
|
||||
username: user.username.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
avatar_url: user.avatar_url.clone(),
|
||||
is_following_me: current_follows.contains(&user.uid),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cards)
|
||||
}
|
||||
}
|
||||
|
||||
173
libs/service/user/user_activity.rs
Normal file
173
libs/service/user/user_activity.rs
Normal file
@ -0,0 +1,173 @@
|
||||
use crate::AppService;
|
||||
use crate::error::AppError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use models::projects::{project, project_activity};
|
||||
use models::users::{user, user_activity_log};
|
||||
use sea_orm::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session::Session;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserActivityItem {
|
||||
pub id: i64,
|
||||
pub activity_type: String, // "auth" | "project"
|
||||
pub action: String,
|
||||
pub title: String,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_name: Option<String>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserActivityResponse {
|
||||
pub items: Vec<UserActivityItem>,
|
||||
pub total: u64,
|
||||
pub page: u64,
|
||||
pub per_page: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::IntoParams)]
|
||||
pub struct UserActivityQuery {
|
||||
pub page: Option<u64>,
|
||||
pub per_page: Option<u64>,
|
||||
}
|
||||
|
||||
impl AppService {
|
||||
pub async fn get_user_activity(
|
||||
&self,
|
||||
context: Session,
|
||||
username: String,
|
||||
query: UserActivityQuery,
|
||||
) -> Result<UserActivityResponse, AppError> {
|
||||
let target_user = user::Entity::find()
|
||||
.filter(user::Column::Username.eq(&username))
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
|
||||
let current_uid = context.user();
|
||||
let is_owner = current_uid
|
||||
.map(|uid| uid == target_user.uid)
|
||||
.unwrap_or(false);
|
||||
|
||||
let page = std::cmp::Ord::max(query.page.unwrap_or(1), 1);
|
||||
let per_page = std::cmp::Ord::min(std::cmp::Ord::max(query.per_page.unwrap_or(20), 1), 100);
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
// User's auth activity log entries
|
||||
let auth_logs: Vec<(i64, String, DateTime<Utc>, serde_json::Value)> =
|
||||
user_activity_log::Entity::find()
|
||||
.filter(user_activity_log::Column::UserUid.eq(target_user.uid))
|
||||
.order_by_desc(user_activity_log::Column::CreatedAt)
|
||||
.select_only()
|
||||
.column(user_activity_log::Column::Id)
|
||||
.column(user_activity_log::Column::Action)
|
||||
.column(user_activity_log::Column::CreatedAt)
|
||||
.column(user_activity_log::Column::Details)
|
||||
.into_tuple()
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
// User's project activity (where user is the actor)
|
||||
let proj_activity = project_activity::Entity::find()
|
||||
.filter(project_activity::Column::Actor.eq(target_user.uid))
|
||||
.order_by_desc(project_activity::Column::CreatedAt)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
// Privacy filter: non-owners only see public project activity
|
||||
let proj_ids: Vec<Uuid> = proj_activity.iter().map(|a| a.project).collect();
|
||||
let proj_map: std::collections::HashMap<Uuid, project::Model> = project::Entity::find()
|
||||
.filter(project::Column::Id.is_in(proj_ids))
|
||||
.all(&self.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p))
|
||||
.collect();
|
||||
|
||||
// Build combined activity list
|
||||
let mut items: Vec<UserActivityItem> = Vec::new();
|
||||
|
||||
// Auth events
|
||||
for (id, action, created_at, metadata) in auth_logs {
|
||||
let title = format_action_title(&action);
|
||||
items.push(UserActivityItem {
|
||||
id,
|
||||
activity_type: "auth".to_string(),
|
||||
action,
|
||||
title,
|
||||
resource_type: None,
|
||||
resource_name: None,
|
||||
metadata: if metadata != serde_json::Value::Null {
|
||||
Some(metadata)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
|
||||
// Project events
|
||||
for activity in proj_activity {
|
||||
// Privacy filter
|
||||
if !is_owner {
|
||||
if let Some(proj) = proj_map.get(&activity.project) {
|
||||
if proj.is_public == false {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
items.push(UserActivityItem {
|
||||
id: activity.id,
|
||||
activity_type: "project".to_string(),
|
||||
action: activity.event_type,
|
||||
title: activity.title,
|
||||
resource_type: Some("project".to_string()),
|
||||
resource_name: proj_map
|
||||
.get(&activity.project)
|
||||
.map(|p| p.name.clone()),
|
||||
metadata: activity.metadata,
|
||||
created_at: activity.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by created_at DESC
|
||||
items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
let total = items.len() as u64;
|
||||
let page_items: Vec<UserActivityItem> = items.into_iter().skip(offset as usize).take(per_page as usize).collect();
|
||||
|
||||
Ok(UserActivityResponse {
|
||||
items: page_items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_action_title(action: &str) -> String {
|
||||
match action {
|
||||
"login" => "Signed in".to_string(),
|
||||
"logout" => "Signed out".to_string(),
|
||||
"register" => "Created account".to_string(),
|
||||
"password_change" => "Changed password".to_string(),
|
||||
"password_reset" => "Reset password".to_string(),
|
||||
"2fa_enabled" => "Enabled 2FA".to_string(),
|
||||
"2fa_disabled" => "Disabled 2FA".to_string(),
|
||||
"ssh_key_add" => "Added SSH key".to_string(),
|
||||
"ssh_key_delete" => "Removed SSH key".to_string(),
|
||||
"ssh_key_update" => "Updated SSH key".to_string(),
|
||||
"ssh_key_revoke" => "Revoked SSH key".to_string(),
|
||||
"access_key_create" => "Created access token".to_string(),
|
||||
"access_key_delete" => "Deleted access token".to_string(),
|
||||
"avatar_upload" => "Updated avatar".to_string(),
|
||||
"profile_update" => "Updated profile".to_string(),
|
||||
_ => format!("Activity: {}", action),
|
||||
}
|
||||
}
|
||||
78
src/app/user/user-activity.tsx
Normal file
78
src/app/user/user-activity.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Activity, Clock } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getUserActivity, type UserActivityItem } from '@/client';
|
||||
import { formatDate } from './utils';
|
||||
|
||||
export function UserActivity({ username }: { username: string }) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['user-activity', username],
|
||||
queryFn: async () => {
|
||||
const resp = await getUserActivity({ path: { username } });
|
||||
return resp.data?.data ?? null;
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading activity...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
Unable to load activity.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.items.length === 0) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Activity className="mx-auto h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No activity yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
if (type === 'auth') return 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400';
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{data.items.map((item: UserActivityItem) => (
|
||||
<Card key={`${item.activity_type}-${item.id}`} className="border-border/40">
|
||||
<CardContent className="flex items-start gap-3 py-4">
|
||||
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium ${getTypeColor(item.activity_type)}`}>
|
||||
{item.activity_type === 'auth' ? 'A' : 'P'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
{item.resource_name && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Project: {item.resource_name}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground/70">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(item.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/app/user/user-following.tsx
Normal file
71
src/app/user/user-following.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getFollowingList } from '@/client';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
|
||||
export function FollowingList({ username }: { username: string }) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['user-following', username],
|
||||
queryFn: async () => {
|
||||
const resp = await getFollowingList({ path: { username } });
|
||||
return resp.data?.data ?? [];
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading following...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
Unable to load following.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="py-12 text-center">
|
||||
<UserPlus className="mx-auto h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">Not following anyone yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{data.map((user: { user_uid: string; username: string; display_name?: string | null; avatar_url?: string | null }) => (
|
||||
<Link
|
||||
key={user.user_uid}
|
||||
to={`/user/${user.username}`}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/40 p-3 transition-all hover:border-border hover:bg-muted/30"
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user.avatar_url ?? undefined} />
|
||||
<AvatarFallback className="text-sm">
|
||||
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{user.display_name || user.username}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">@{user.username}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
src/app/user/user-security.tsx
Normal file
149
src/app/user/user-security.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Copy, Key, Shield, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { listSshKeys, listAccessKeys } from '@/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function SecurityTab() {
|
||||
const { data: sshKeys, isLoading: sshLoading } = useQuery({
|
||||
queryKey: ['my-ssh-keys'],
|
||||
queryFn: async () => {
|
||||
const resp = await listSshKeys();
|
||||
return resp.data?.data?.keys ?? [];
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: accessKeys, isLoading: accessLoading } = useQuery({
|
||||
queryKey: ['my-access-keys'],
|
||||
queryFn: async () => {
|
||||
const resp = await listAccessKeys();
|
||||
return resp.data?.data?.access_keys ?? [];
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const isLoading = sshLoading || accessLoading;
|
||||
|
||||
const copyFingerprint = (fingerprint: string) => {
|
||||
navigator.clipboard.writeText(fingerprint).then(() => {
|
||||
toast.success('Copied fingerprint');
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading security settings...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* SSH Keys */}
|
||||
<Card className="border-border/40">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Key className="h-4 w-4" />
|
||||
SSH Keys
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(sshKeys ?? []).length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">No SSH keys added</p>
|
||||
) : (
|
||||
(sshKeys ?? []).map((key: {
|
||||
id: number;
|
||||
title: string;
|
||||
fingerprint: string;
|
||||
key_type: string;
|
||||
is_revoked: boolean;
|
||||
created_at: string;
|
||||
}) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border/40 p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{key.title}</p>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase">{key.key_type}</span>
|
||||
{key.is_revoked && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" /> Revoked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyFingerprint(key.fingerprint)}
|
||||
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{key.fingerprint}
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="mt-2 w-full" onClick={() => window.location.href = '/settings/keys'}>
|
||||
Manage SSH Keys
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Personal Access Tokens */}
|
||||
<Card className="border-border/40">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Shield className="h-4 w-4" />
|
||||
Personal Access Tokens
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(accessKeys ?? []).length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">No access tokens created</p>
|
||||
) : (
|
||||
(accessKeys ?? []).map((key: {
|
||||
id: number;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
is_revoked: boolean;
|
||||
created_at: string;
|
||||
}) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border/40 p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{key.name}</p>
|
||||
{key.is_revoked && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" /> Revoked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{(key.scopes ?? []).map((scope: string) => (
|
||||
<span key={scope} className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="mt-2 w-full" onClick={() => window.location.href = '/settings/tokens'}>
|
||||
Manage Access Tokens
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/app/user/user-stars.tsx
Normal file
107
src/app/user/user-stars.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Star, FolderGit2, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getUserStars } from '@/client';
|
||||
|
||||
export function StarsList({ username }: { username: string }) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['user-stars', username],
|
||||
queryFn: async () => {
|
||||
const resp = await getUserStars({ path: { username } });
|
||||
return resp.data?.data ?? null;
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
|
||||
Loading stars...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
Unable to load stars.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hasRepos = (data.repos ?? []).length > 0;
|
||||
const hasProjects = (data.projects ?? []).length > 0;
|
||||
|
||||
if (!hasRepos && !hasProjects) {
|
||||
return (
|
||||
<Card className="border-border/40">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Star className="mx-auto h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No starred repos or projects yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasRepos && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">Repositories</h3>
|
||||
<div className="space-y-2">
|
||||
{(data.repos ?? []).map((repo: { uid: string; repo_name: string; owner: string; description?: string | null; is_private: boolean }) => (
|
||||
<Link
|
||||
key={repo.uid}
|
||||
to={`/repository/${repo.owner}/${repo.repo_name}`}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/40 p-3 transition-all hover:border-border hover:bg-muted/30"
|
||||
>
|
||||
<Star className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{repo.owner}/{repo.repo_name}</p>
|
||||
{repo.description && (
|
||||
<p className="truncate text-xs text-muted-foreground">{repo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{repo.is_private && (
|
||||
<span className="shrink-0 rounded border border-border/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">Private</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasProjects && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">Projects</h3>
|
||||
<div className="space-y-2">
|
||||
{(data.projects ?? []).map((proj: { uid: string; name: string; display_name: string; description?: string | null; is_public: boolean }) => (
|
||||
<Link
|
||||
key={proj.uid}
|
||||
to={`/project/${proj.name}`}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/40 p-3 transition-all hover:border-border hover:bg-muted/30"
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{proj.display_name}</p>
|
||||
{proj.description && (
|
||||
<p className="truncate text-xs text-muted-foreground">{proj.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{!proj.is_public && (
|
||||
<span className="shrink-0 rounded border border-border/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">Private</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { useContext } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
Building2,
|
||||
Calendar,
|
||||
@ -10,6 +11,7 @@ import {
|
||||
Loader2,
|
||||
MapPin,
|
||||
Settings,
|
||||
Shield,
|
||||
Star,
|
||||
UserPlus,
|
||||
UserRoundCheck,
|
||||
@ -22,6 +24,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { getContributionHeatmap, getSubscriberCount, getUserInfo, getUserProjects, getUserRepos, subscribeTarget, unsubscribeTarget } from '@/client';
|
||||
import { UserContext } from '@/contexts/user-context';
|
||||
import { UserActivity } from './user-activity';
|
||||
import { FollowingList } from './user-following';
|
||||
import { StarsList } from './user-stars';
|
||||
import { SecurityTab } from './user-security';
|
||||
import { formatDate } from './utils';
|
||||
|
||||
const resolveCount = (payload: unknown): number => {
|
||||
if (typeof payload === 'number') return payload;
|
||||
@ -43,17 +50,6 @@ const resolveCount = (payload: unknown): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const resolveHeatLevel = (count: number, max: number) => {
|
||||
if (count <= 0) return 0;
|
||||
if (max <= 1) return 4;
|
||||
@ -246,6 +242,10 @@ export function UserProfile() {
|
||||
const isAuth = currentUser !== null;
|
||||
const queryClient = useQueryClient();
|
||||
const nav = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get('tab') || 'overview';
|
||||
|
||||
const setTab = (tab: string) => setSearchParams({ tab });
|
||||
|
||||
const userInfoKey = ['user-info', targetUser] as const;
|
||||
const subscriberCountKey = ['user-subscriber-count', targetUser] as const;
|
||||
@ -537,7 +537,83 @@ export function UserProfile() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contribution Heatmap */}
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-1 border-b border-border/40 -mb-2">
|
||||
<button
|
||||
onClick={() => setTab('overview')}
|
||||
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'overview'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('activity')}
|
||||
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'activity'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
Activity
|
||||
</button>
|
||||
{/*
|
||||
<button
|
||||
onClick={() => setTab('followers')}
|
||||
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'followers'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Followers
|
||||
</button>
|
||||
*/}
|
||||
<button
|
||||
onClick={() => setTab('following')}
|
||||
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'following'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Following
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('stars')}
|
||||
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'stars'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
Stars
|
||||
</button>
|
||||
{userInfo.is_owner && (
|
||||
<button
|
||||
onClick={() => setTab('security')}
|
||||
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'security'
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Security
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div>{/* Contribution Heatmap */}
|
||||
<ContributionHeatmap
|
||||
totalContributions={contributionHeatmap?.total_contributions ?? 0}
|
||||
heatmap={contributionHeatmap?.heatmap ?? []}
|
||||
@ -631,6 +707,20 @@ export function UserProfile() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'activity' && (
|
||||
<UserActivity username={targetUser} />
|
||||
)}
|
||||
{activeTab === 'following' && (
|
||||
<FollowingList username={targetUser} />
|
||||
)}
|
||||
{activeTab === 'stars' && (
|
||||
<StarsList username={targetUser} />
|
||||
)}
|
||||
{activeTab === 'security' && userInfo.is_owner && (
|
||||
<SecurityTab />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
10
src/app/user/utils.ts
Normal file
10
src/app/user/utils.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export const formatDate = (value?: string | null) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user