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:
ZhenYi 2026-04-22 22:39:14 +08:00
parent f67c788cbe
commit 80e2201b8b
15 changed files with 1009 additions and 13 deletions

View File

@ -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,

View File

@ -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
View 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())
}

View File

@ -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())
}

View 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())
}

View File

@ -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
View 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,
})
}
}

View File

@ -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)
}
}

View 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),
}
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View File

@ -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
View 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',
});
};