diff --git a/libs/api/openapi.rs b/libs/api/openapi.rs index 78cb2db..d23eafa 100644 --- a/libs/api/openapi.rs +++ b/libs/api/openapi.rs @@ -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, diff --git a/libs/api/user/mod.rs b/libs/api/user/mod.rs index a201403..f2b946a 100644 --- a/libs/api/user/mod.rs +++ b/libs/api/user/mod.rs @@ -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), diff --git a/libs/api/user/stars.rs b/libs/api/user/stars.rs new file mode 100644 index 0000000..4e24bce --- /dev/null +++ b/libs/api/user/stars.rs @@ -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), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), +), + tag = "User" +)] +pub async fn get_user_stars( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let username = path.into_inner(); + let resp = service.get_user_stars(session, username).await?; + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/api/user/subscribe.rs b/libs/api/user/subscribe.rs index cae48fa..ae52623 100644 --- a/libs/api/user/subscribe.rs +++ b/libs/api/user/subscribe.rs @@ -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>), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), +), + tag = "User" +)] +pub async fn get_following_list( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let username = path.into_inner(); + let resp = service.user_get_following_list(session, username).await?; + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/api/user/user_activity.rs b/libs/api/user/user_activity.rs new file mode 100644 index 0000000..6c7fc4d --- /dev/null +++ b/libs/api/user/user_activity.rs @@ -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, Query), + ("per_page" = Option, Query), + ), + responses( + (status = 200, description = "Get user activity", body = ApiResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), +), + tag = "User" +)] +pub async fn get_user_activity( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let username = path.into_inner(); + let resp = service + .get_user_activity(session, username, query.into_inner()) + .await?; + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/service/user/mod.rs b/libs/service/user/mod.rs index ecc4bb0..5bf0af8 100644 --- a/libs/service/user/mod.rs +++ b/libs/service/user/mod.rs @@ -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; diff --git a/libs/service/user/stars.rs b/libs/service/user/stars.rs new file mode 100644 index 0000000..150c16b --- /dev/null +++ b/libs/service/user/stars.rs @@ -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, + pub is_private: bool, + pub default_branch: String, + pub starred_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ProjectFollowItem { + pub uid: Uuid, + pub name: String, + pub display_name: String, + pub description: Option, + pub is_public: bool, + pub followed_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserStarsResponse { + pub repos: Vec, + pub projects: Vec, + pub total: u64, +} + +impl AppService { + pub async fn get_user_stars( + &self, + context: Session, + username: String, + ) -> Result { + 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 = stars.iter().map(|s| s.repo).collect(); + + let repos: std::collections::HashMap = 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 = repos.values().map(|r| r.project).collect(); + let projects_map: std::collections::HashMap = 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 = 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 = follows.iter().map(|f| f.project).collect(); + + let followed_projects: std::collections::HashMap = 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 = 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, + }) + } +} diff --git a/libs/service/user/subscribe.rs b/libs/service/user/subscribe.rs index c538b88..fd1d931 100644 --- a/libs/service/user/subscribe.rs +++ b/libs/service/user/subscribe.rs @@ -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, + pub avatar_url: Option, + pub is_following_me: bool, +} + impl From 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, 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 = following.iter().map(|f| f.target).collect(); + + let followed_users: std::collections::HashMap = 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 = 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::() + .all(&self.db) + .await? + .into_iter() + .collect() + } else { + std::collections::HashSet::new() + }; + + let mut cards: Vec = 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) + } } diff --git a/libs/service/user/user_activity.rs b/libs/service/user/user_activity.rs new file mode 100644 index 0000000..1165d1a --- /dev/null +++ b/libs/service/user/user_activity.rs @@ -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, + pub resource_name: Option, + pub metadata: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserActivityResponse { + pub items: Vec, + pub total: u64, + pub page: u64, + pub per_page: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize, utoipa::IntoParams)] +pub struct UserActivityQuery { + pub page: Option, + pub per_page: Option, +} + +impl AppService { + pub async fn get_user_activity( + &self, + context: Session, + username: String, + query: UserActivityQuery, + ) -> Result { + 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, 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 = proj_activity.iter().map(|a| a.project).collect(); + let proj_map: std::collections::HashMap = 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 = 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 = 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), + } +} diff --git a/src/app/user/user-activity.tsx b/src/app/user/user-activity.tsx new file mode 100644 index 0000000..d27572a --- /dev/null +++ b/src/app/user/user-activity.tsx @@ -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 ( + + + Loading activity... + + + ); + } + + if (isError || !data) { + return ( + + + Unable to load activity. + + + ); + } + + if (data.items.length === 0) { + return ( + + + +

No activity yet

+
+
+ ); + } + + 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 ( +
+ {data.items.map((item: UserActivityItem) => ( + + + + {item.activity_type === 'auth' ? 'A' : 'P'} + +
+

{item.title}

+ {item.resource_name && ( +

+ Project: {item.resource_name} +

+ )} +
+ + {formatDate(item.created_at)} +
+
+
+
+ ))} +
+ ); +} diff --git a/src/app/user/user-following.tsx b/src/app/user/user-following.tsx new file mode 100644 index 0000000..2ca6720 --- /dev/null +++ b/src/app/user/user-following.tsx @@ -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 ( + + + Loading following... + + + ); + } + + if (isError || !data) { + return ( + + + Unable to load following. + + + ); + } + + if (data.length === 0) { + return ( + + + +

Not following anyone yet

+
+
+ ); + } + + return ( +
+ {data.map((user: { user_uid: string; username: string; display_name?: string | null; avatar_url?: string | null }) => ( + + + + + {(user.display_name || user.username).charAt(0).toUpperCase()} + + +
+

{user.display_name || user.username}

+

@{user.username}

+
+ + ))} +
+ ); +} diff --git a/src/app/user/user-security.tsx b/src/app/user/user-security.tsx new file mode 100644 index 0000000..33958d4 --- /dev/null +++ b/src/app/user/user-security.tsx @@ -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 ( + + + + Loading security settings... + + + ); + } + + return ( +
+ {/* SSH Keys */} + + + + + SSH Keys + + + + {(sshKeys ?? []).length === 0 ? ( +

No SSH keys added

+ ) : ( + (sshKeys ?? []).map((key: { + id: number; + title: string; + fingerprint: string; + key_type: string; + is_revoked: boolean; + created_at: string; + }) => ( +
+
+
+

{key.title}

+ {key.key_type} + {key.is_revoked && ( + + Revoked + + )} +
+ +
+
+ )) + )} + +
+
+ + {/* Personal Access Tokens */} + + + + + Personal Access Tokens + + + + {(accessKeys ?? []).length === 0 ? ( +

No access tokens created

+ ) : ( + (accessKeys ?? []).map((key: { + id: number; + name: string; + scopes: string[]; + is_revoked: boolean; + created_at: string; + }) => ( +
+
+
+

{key.name}

+ {key.is_revoked && ( + + Revoked + + )} +
+
+ {(key.scopes ?? []).map((scope: string) => ( + + {scope} + + ))} +
+
+
+ )) + )} + +
+
+
+ ); +} diff --git a/src/app/user/user-stars.tsx b/src/app/user/user-stars.tsx new file mode 100644 index 0000000..f5f94cb --- /dev/null +++ b/src/app/user/user-stars.tsx @@ -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 ( + + + + Loading stars... + + + ); + } + + if (isError || !data) { + return ( + + + Unable to load stars. + + + ); + } + + const hasRepos = (data.repos ?? []).length > 0; + const hasProjects = (data.projects ?? []).length > 0; + + if (!hasRepos && !hasProjects) { + return ( + + + +

No starred repos or projects yet

+
+
+ ); + } + + return ( +
+ {hasRepos && ( +
+

Repositories

+
+ {(data.repos ?? []).map((repo: { uid: string; repo_name: string; owner: string; description?: string | null; is_private: boolean }) => ( + + +
+

{repo.owner}/{repo.repo_name}

+ {repo.description && ( +

{repo.description}

+ )} +
+ {repo.is_private && ( + Private + )} + + ))} +
+
+ )} + + {hasProjects && ( +
+

Projects

+
+ {(data.projects ?? []).map((proj: { uid: string; name: string; display_name: string; description?: string | null; is_public: boolean }) => ( + + +
+

{proj.display_name}

+ {proj.description && ( +

{proj.description}

+ )} +
+ {!proj.is_public && ( + Private + )} + + ))} +
+
+ )} +
+ ); +} diff --git a/src/app/user/user.tsx b/src/app/user/user.tsx index e08b972..3eebb48 100644 --- a/src/app/user/user.tsx +++ b/src/app/user/user.tsx @@ -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() { - {/* Contribution Heatmap */} + {/* Tab Navigation */} +
+ + + {/* + + */} + + + {userInfo.is_owner && ( + + )} +
+ + {/* Tab Content */} + {activeTab === 'overview' && ( +
{/* Contribution Heatmap */} +
+ )} + {activeTab === 'activity' && ( + + )} + {activeTab === 'following' && ( + + )} + {activeTab === 'stars' && ( + + )} + {activeTab === 'security' && userInfo.is_owner && ( + + )} ); diff --git a/src/app/user/utils.ts b/src/app/user/utils.ts new file mode 100644 index 0000000..79a9502 --- /dev/null +++ b/src/app/user/utils.ts @@ -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', + }); +};