refactor(ui): update me/profile pages for new theme system

Update MeLayout, MePage, and all me/components (ActivityTimeline,
NotificationList, ProfileHeader, ProjectList, RepoList) to use
CSS variable-based theme tokens and improved layout.
This commit is contained in:
ZhenYi 2026-05-18 20:44:29 +08:00
parent cab064f83f
commit 16739d3cf8
7 changed files with 920 additions and 549 deletions

View File

@ -1,64 +1,81 @@
import { Outlet, useLocation } from "react-router-dom";
import { useState } from "react";
import { PanelLeftOpen } from "lucide-react";
import { ServerIconRail } from "@/components/layout/ServerIconRail";
import { MeSidebar } from "./components/MeSidebar";
import { Header } from "@/components/layout/Header";
import { useIsMobile } from "@/hooks/use-mobile";
import { Outlet, useLocation } from "react-router-dom"
import { useState } from "react"
import { PanelLeftOpen } from "lucide-react"
import { ServerIconRail } from "@/components/layout/ServerIconRail"
import { MeSidebar } from "./components/MeSidebar"
import { Header } from "@/components/layout/Header"
import { useIsMobile } from "@/hooks/use-mobile"
export function MeLayout() {
const isMobile = useIsMobile();
const location = useLocation();
const isExplore = location.pathname === "/explore";
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const isMobile = useIsMobile()
const location = useLocation()
const isExplore = location.pathname === "/explore"
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
return (
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}>
<div
className="relative flex h-screen overflow-hidden"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage:
"radial-gradient(circle at top left, color-mix(in oklch, var(--accent) 9%, transparent) 0, transparent 28%), radial-gradient(circle at bottom right, color-mix(in oklch, var(--accent) 4%, transparent) 0, transparent 24%)",
}}
/>
{!isMobile && <ServerIconRail />}
{!isExplore && (
<div className="relative flex shrink-0">
<div
style={{
width: isSidebarCollapsed ? 0 : 220,
transition: "width 0.2s ease",
overflow: "hidden",
backgroundColor: "var(--surface-sidebar)",
}}
>
{!isSidebarCollapsed && (
<MeSidebar onCollapse={() => setIsSidebarCollapsed(true)} />
<div className="relative flex shrink-0">
<div
style={{
width: isSidebarCollapsed ? 0 : 220,
transition: "width 0.2s ease",
overflow: "hidden",
backgroundColor: "var(--surface-sidebar)",
}}
>
{!isSidebarCollapsed && (
<MeSidebar onCollapse={() => setIsSidebarCollapsed(true)} />
)}
</div>
{/* Expand button - visible when collapsed */}
{isSidebarCollapsed && (
<button
onClick={() => setIsSidebarCollapsed(false)}
className="absolute flex h-10 w-5 cursor-pointer items-center justify-center transition-colors"
style={{
color: "var(--text-muted)",
backgroundColor: "var(--surface-sidebar)",
right: -10,
top: "50%",
transform: "translateY(-50%)",
borderRadius: "0 4px 4px 0",
border: "0.5px solid var(--border-subtle)",
borderLeft: "none",
zIndex: 10,
}}
title="Expand sidebar"
>
<PanelLeftOpen className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Expand button - visible when collapsed */}
{isSidebarCollapsed && (
<button
onClick={() => setIsSidebarCollapsed(false)}
className="absolute flex items-center justify-center w-5 h-10 cursor-pointer transition-colors"
style={{
color: "var(--text-muted)",
backgroundColor: "var(--surface-sidebar)",
right: -10,
top: "50%",
transform: "translateY(-50%)",
borderRadius: "0 4px 4px 0",
border: "0.5px solid var(--border-subtle)",
borderLeft: "none",
zIndex: 10,
}}
title="Expand sidebar"
>
<PanelLeftOpen className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
<div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ backgroundColor: "var(--surface-ground)" }}>
<div
className="relative z-10 flex min-w-0 flex-1 flex-col overflow-hidden"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<Header />
<main className="flex-1 overflow-y-auto" style={{ backgroundColor: "var(--surface-ground)" }}>
<main
className="flex-1 overflow-y-auto"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<Outlet />
</main>
</div>
</div>
);
)
}

View File

@ -1,4 +1,4 @@
import { useLocation } from "react-router-dom";
import { useLocation } from "react-router-dom"
import {
useUserInfoQuery,
useUserActivityQuery,
@ -11,98 +11,147 @@ import {
useUserFollowersQuery,
useFollowMutation,
useUnfollowMutation,
} from "@/hooks/useUserQuery";
import { ProfileHeader } from "./components/ProfileHeader";
import { ActivityTimeline } from "./components/ActivityTimeline";
import { ContributionHeatmap } from "./components/ContributionHeatmap";
import { UserCardList } from "./components/UserCardList";
import { FollowerCardList } from "./components/FollowerCardList";
import { RepoList } from "./components/RepoList";
import { ProjectList } from "./components/ProjectList";
import { NotificationList } from "./components/NotificationList";
import { Loader2 } from "lucide-react";
import { useCurrentUserQuery } from "@/hooks/useAuth";
import { t } from "@/i18n/T";
} from "@/hooks/useUserQuery"
import { ProfileHeader } from "./components/ProfileHeader"
import { ActivityTimeline } from "./components/ActivityTimeline"
import { ContributionHeatmap } from "./components/ContributionHeatmap"
import { UserCardList } from "./components/UserCardList"
import { FollowerCardList } from "./components/FollowerCardList"
import { RepoList } from "./components/RepoList"
import { ProjectList } from "./components/ProjectList"
import { NotificationList } from "./components/NotificationList"
import { Loader2 } from "lucide-react"
import { useCurrentUserQuery } from "@/hooks/useAuth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
EmptyDescription,
} from "@/components/ui/empty"
import { t } from "@/i18n/T"
import type { ReactNode } from "react"
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle
className="text-[11px] tracking-[0.22em] uppercase"
style={{ color: "var(--text-tertiary)" }}
>
{title}
</CardTitle>
</CardHeader>
<CardContent className="pb-4">{children}</CardContent>
</Card>
)
}
export function MePage() {
const location = useLocation();
const { data: currentUser, isLoading: isAuthLoading } = useCurrentUserQuery();
const username = currentUser?.username || "";
const location = useLocation()
const { data: currentUser, isLoading: isAuthLoading } = useCurrentUserQuery()
const username = currentUser?.username || ""
const { data: summary, isLoading: isSummaryLoading } = useUserSummaryQuery(username);
const { data: summary, isLoading: isSummaryLoading } =
useUserSummaryQuery(username)
const { data: userInfo, isLoading: isInfoLoading } =
useUserInfoQuery(username)
const { data: heatmapData } = useMyHeatmapQuery()
const { data: repos, isLoading: isReposLoading } = useMyReposQuery()
const { data: projects, isLoading: isProjectsLoading } = useMyProjectsQuery()
const { data: userInfo, isLoading: isInfoLoading } = useUserInfoQuery(username);
const { data: heatmapData } = useMyHeatmapQuery();
const { data: repos, isLoading: isReposLoading } = useMyReposQuery();
const { data: projects, isLoading: isProjectsLoading } = useMyProjectsQuery();
const path = location.pathname
let activeSection = "overview"
if (path.includes("/repositories")) activeSection = "repositories"
else if (path.includes("/projects")) activeSection = "projects"
else if (path.includes("/activity")) activeSection = "activity"
else if (path.includes("/stars")) activeSection = "stars"
else if (path.includes("/following")) activeSection = "following"
else if (path.includes("/followers")) activeSection = "followers"
else if (path.includes("/notify")) activeSection = "notify"
// Determine active section based on URL
const path = location.pathname;
let activeSection = "overview";
if (path.includes("/repositories")) activeSection = "repositories";
else if (path.includes("/projects")) activeSection = "projects";
else if (path.includes("/activity")) activeSection = "activity";
else if (path.includes("/stars")) activeSection = "stars";
else if (path.includes("/following")) activeSection = "following";
else if (path.includes("/followers")) activeSection = "followers";
else if (path.includes("/notify")) activeSection = "notify";
const { data: activityData, isLoading: isActivityLoading } =
useUserActivityQuery(username, 1, 20, {
enabled: activeSection === "activity" || activeSection === "overview",
})
const { data: starsData } = useUserStarsQuery(username, {
enabled: activeSection === "stars",
})
const { data: following, isLoading: isFollowingLoading } =
useUserFollowingQuery(username, { enabled: activeSection === "following" })
const { data: followers, isLoading: isFollowersLoading } =
useUserFollowersQuery(username, { enabled: activeSection === "followers" })
// Conditional fetching for specific sections
const { data: activityData, isLoading: isActivityLoading } = useUserActivityQuery(username, 1, 20, { enabled: activeSection === "activity" || activeSection === "overview" });
const { data: starsData } = useUserStarsQuery(username, { enabled: activeSection === "stars" });
const { data: following, isLoading: isFollowingLoading } = useUserFollowingQuery(username, { enabled: activeSection === "following" });
const { data: followers, isLoading: isFollowersLoading } = useUserFollowersQuery(username, { enabled: activeSection === "followers" });
const followMutation = useFollowMutation()
const unfollowMutation = useUnfollowMutation()
// Follow/Unfollow mutations
const followMutation = useFollowMutation();
const unfollowMutation = useUnfollowMutation();
const handleFollowToggle = async (username: string, isFollowing: boolean) => {
const handleFollowToggle = async (
targetUsername: string,
isFollowing: boolean
) => {
try {
if (isFollowing) {
await unfollowMutation.mutateAsync(username);
await unfollowMutation.mutateAsync(targetUsername)
} else {
await followMutation.mutateAsync(username);
await followMutation.mutateAsync(targetUsername)
}
} catch (err) {
console.error("Follow toggle failed:", err);
console.error("Follow toggle failed:", err)
}
};
}
if (isAuthLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin" style={{ color: "var(--accent)" }} />
<div className="flex h-full items-center justify-center">
<Loader2
className="size-8 animate-spin"
style={{ color: "var(--accent)" }}
/>
</div>
);
)
}
if (!currentUser) {
return (
<div className="flex items-center justify-center h-full" style={{ color: "var(--text-muted)" }}>
<div className="text-center">
<p className="text-xl font-bold mb-2">{t("me.user_not_found")}</p>
<p>{t("me.please_login")}</p>
</div>
<div className="flex h-full items-center justify-center">
<Empty className="max-w-md border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
<EmptyHeader>
<EmptyMedia variant="icon">
<Loader2 />
</EmptyMedia>
<EmptyTitle>{t("me.user_not_found")}</EmptyTitle>
<EmptyDescription>{t("me.please_login")}</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
);
)
}
const renderContent = () => {
switch (activeSection) {
case "repositories":
return <RepoList repos={repos ?? []} isLoading={isReposLoading} />;
return <RepoList repos={repos ?? []} isLoading={isReposLoading} />
case "projects":
return <ProjectList projects={projects ?? []} isLoading={isProjectsLoading} />;
return (
<ProjectList
projects={projects ?? []}
isLoading={isProjectsLoading}
/>
)
case "activity":
return (
<div className="rounded-xl p-6 border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}>
<ActivityTimeline items={activityData?.items ?? []} isLoading={isActivityLoading} />
</div>
);
<ActivityTimeline
items={activityData?.items ?? []}
isLoading={isActivityLoading}
/>
)
case "stars":
return (
<RepoList repos={starsData?.repos.map(s => ({
<RepoList
repos={
starsData?.repos.map((s) => ({
uid: s.uid,
repo_name: s.repo_name,
project_name: s.owner,
@ -111,73 +160,97 @@ export function MePage() {
is_private: s.is_private,
updated_at: s.starred_at,
storage_path: "",
created_at: s.starred_at
})) ?? []} isLoading={!starsData} />
);
created_at: s.starred_at,
})) ?? []
}
isLoading={!starsData}
/>
)
case "following":
return isFollowingLoading ? (
<div className="flex justify-center py-12"><Loader2 className="w-6 h-6 animate-spin" /></div>
<div className="flex justify-center py-12">
<Loader2 className="size-6 animate-spin" />
</div>
) : (
<UserCardList users={following ?? []} onToggleFollow={handleFollowToggle} />
);
<UserCardList
users={following ?? []}
onToggleFollow={handleFollowToggle}
/>
)
case "followers":
return isFollowersLoading ? (
<div className="flex justify-center py-12"><Loader2 className="w-6 h-6 animate-spin" /></div>
<div className="flex justify-center py-12">
<Loader2 className="size-6 animate-spin" />
</div>
) : (
<FollowerCardList users={followers ?? []} />
);
<FollowerCardList users={followers ?? []} />
)
case "notify":
return (
<div className="rounded-xl p-6 border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}>
<NotificationList />
</div>
);
return <NotificationList />
default:
return (
<div className="space-y-6">
{(summary?.heatmap || heatmapData) && <ContributionHeatmap data={(summary?.heatmap || heatmapData)!} />}
<div className="space-y-6">
{(summary?.heatmap || heatmapData) && (
<Card>
<CardContent className="pt-4">
<ContributionHeatmap
data={(summary?.heatmap || heatmapData)!}
/>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6">
<div className="xl:col-span-7 space-y-6">
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-[11px] font-semibold uppercase tracking-wider" style={{ color: "var(--text-tertiary)" }}>{t("me.recent_repos")}</h2>
</div>
<RepoList repos={summary?.repos || repos?.slice(0, 4) || []} isLoading={(isSummaryLoading && isReposLoading) && !summary} />
</section>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12">
<div className="space-y-6 xl:col-span-7">
<Section title={t("me.recent_repos")}>
<RepoList
repos={summary?.repos || repos?.slice(0, 4) || []}
isLoading={isSummaryLoading && isReposLoading && !summary}
/>
</Section>
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-[11px] font-semibold uppercase tracking-wider" style={{ color: "var(--text-tertiary)" }}>{t("me.top_projects")}</h2>
</div>
<ProjectList projects={summary?.projects || projects?.slice(0, 4) || []} isLoading={(isSummaryLoading && isProjectsLoading) && !summary} />
</section>
</div>
<Section title={t("me.top_projects")}>
<ProjectList
projects={summary?.projects || projects?.slice(0, 4) || []}
isLoading={
isSummaryLoading && isProjectsLoading && !summary
}
/>
</Section>
</div>
<div className="xl:col-span-5">
<section>
<h2 className="text-[11px] font-semibold uppercase tracking-wider mb-3" style={{ color: "var(--text-tertiary)" }}>{t("me.latest_activity")}</h2>
<div className="rounded-xl p-5 border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}>
<ActivityTimeline items={summary?.activity || activityData?.items.slice(0, 8) || []} isLoading={(isSummaryLoading && isActivityLoading) && !summary} />
</div>
</section>
</div>
</div>
<div className="xl:col-span-5">
<Section title={t("me.latest_activity")}>
<ActivityTimeline
items={
summary?.activity || activityData?.items.slice(0, 8) || []
}
isLoading={
isSummaryLoading && isActivityLoading && !summary
}
/>
</Section>
</div>
</div>
);
</div>
)
}
};
}
return (
<div className="p-8 h-full overflow-y-auto" style={{ backgroundColor: "var(--surface-ground)" }}>
<div className="max-w-6xl mx-auto">
<ProfileHeader user={summary?.info || userInfo} isMe={true} isLoading={(isSummaryLoading && isInfoLoading) && !summary} starsCount={summary?.stars_count} followerCount={summary?.follower_count} />
<div className="mt-8">
{renderContent()}
</div>
<div className="h-full overflow-y-auto bg-[var(--surface-ground)] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<div className="mx-auto flex max-w-6xl flex-col gap-6">
<ProfileHeader
user={summary?.info || userInfo}
isMe={true}
isLoading={isSummaryLoading && isInfoLoading && !summary}
starsCount={summary?.stars_count}
followerCount={summary?.follower_count}
/>
{renderContent()}
</div>
</div>
);
)
}
export default MePage;
export default MePage

View File

@ -1,5 +1,5 @@
import { memo } from "react";
import { formatDistanceToNow } from "date-fns";
import { memo } from "react"
import { formatDistanceToNow } from "date-fns"
import {
History,
LogIn,
@ -13,14 +13,22 @@ import {
AlertCircle,
Settings,
Image as ImageIcon,
} from "lucide-react";
import type { UserActivityItem } from "@/client/model";
import { Skeleton } from "@/components/ui/skeleton";
import type { ComponentType, SVGProps } from "react";
} from "lucide-react"
import type { UserActivityItem } from "@/client/model"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import type { ComponentType, SVGProps } from "react"
interface ActivityTimelineProps {
items: UserActivityItem[];
isLoading?: boolean;
items: UserActivityItem[]
isLoading?: boolean
}
const ICON_MAP: Record<string, ComponentType<SVGProps<SVGSVGElement>>> = {
@ -35,64 +43,96 @@ const ICON_MAP: Record<string, ComponentType<SVGProps<SVGSVGElement>>> = {
issue_create: AlertCircle,
profile_update: Settings,
avatar_upload: ImageIcon,
};
}
export const ActivityTimeline = memo(function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
export const ActivityTimeline = memo(function ActivityTimeline({
items,
isLoading,
}: ActivityTimelineProps) {
if (isLoading) {
return (
<div className="flow-root">
<ul className="space-y-0">
{[...Array(6)].map((_, i) => (
<li key={i} className="flex items-start gap-3 py-3 border-b border-[0.5px] last:border-0" style={{ borderColor: "var(--border-subtle)" }}>
<Skeleton className="w-7 h-7 rounded-full shrink-0 mt-0.5" />
<div className="flex flex-col flex-1 gap-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-2 w-24" />
</div>
</li>
))}
</ul>
<div className="space-y-0">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-start gap-3 py-3">
<Skeleton className="mt-0.5 size-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-2.5 w-24" />
</div>
</div>
))}
</div>
);
)
}
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12" style={{ color: "var(--text-tertiary)" }}>
<History className="w-12 h-12 mb-4 opacity-20" />
<p className="text-[13px]">No recent activity</p>
</div>
);
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
<EmptyHeader>
<EmptyMedia variant="icon">
<History />
</EmptyMedia>
<EmptyTitle>No recent activity</EmptyTitle>
<EmptyDescription></EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return (
<div className="flow-root">
<ul className="space-y-0">
{items.map((item) => {
const Icon = ICON_MAP[item.action] || History;
return (
<li key={item.id} className="flex items-start gap-3 py-3 border-b border-[0.5px] last:border-0" style={{ borderColor: "var(--border-subtle)" }}>
<div className="space-y-0">
{items.map((item, index) => {
const Icon = ICON_MAP[item.action] || History
return (
<div key={item.id}>
<div className="flex items-start gap-3 py-3">
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5"
style={{ backgroundColor: "var(--accent-bg)", color: "var(--accent)" }}
className="flex size-7 shrink-0 items-center justify-center rounded-full"
style={{
backgroundColor: "var(--accent-bg)",
color: "var(--accent)",
}}
>
<Icon className="h-[13px] w-[13px]" aria-hidden="true" />
<Icon className="size-3.5" aria-hidden="true" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<p className="text-[12px] leading-relaxed" style={{ color: "var(--text-secondary)" }}>
<span style={{ color: "var(--text-primary)", fontWeight: 500 }}>{item.title}</span>
<div className="min-w-0 flex-1">
<p
className="text-[12px] leading-relaxed"
style={{ color: "var(--text-secondary)" }}
>
<span
style={{ color: "var(--text-primary)", fontWeight: 500 }}
>
{item.title}
</span>
{item.resource_name && (
<> in <span className="font-semibold" style={{ color: "var(--text-primary)" }}>{item.resource_name}</span></>
<>
{" "}
in{" "}
<span
className="font-semibold"
style={{ color: "var(--text-primary)" }}
>
{item.resource_name}
</span>
</>
)}
</p>
<time className="text-[11px] mt-1" style={{ color: "var(--text-tertiary)" }} dateTime={item.created_at}>
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
<time
className="mt-1 text-[11px]"
style={{ color: "var(--text-tertiary)" }}
dateTime={item.created_at}
>
{formatDistanceToNow(new Date(item.created_at), {
addSuffix: true,
})}
</time>
</div>
</li>
);
})}
</ul>
</div>
{index < items.length - 1 && <Separator className="opacity-60" />}
</div>
)
})}
</div>
);
});
)
})

View File

@ -1,9 +1,22 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { notificationList, notificationMarkRead, notificationMarkAllRead } from "@/client/api";
import type { NotificationResponse } from "@/client/model";
import { Bell, CheckCheck, Mail, MailOpen, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
notificationList,
notificationMarkRead,
notificationMarkAllRead,
} from "@/client/api"
import type { NotificationResponse } from "@/client/model"
import { Bell, CheckCheck, Mail, MailOpen, Loader2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
mention: "Mention",
@ -13,153 +26,211 @@ const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
room_deleted: "Room Deleted",
system_announcement: "Announcement",
project_invitation: "Project Invitation",
};
}
function NotificationItem({ notification }: { notification: NotificationResponse }) {
const queryClient = useQueryClient();
function NotificationItem({
notification,
}: {
notification: NotificationResponse
}) {
const queryClient = useQueryClient()
const markReadMutation = useMutation({
mutationFn: () => notificationMarkRead(notification.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationList"] });
queryClient.invalidateQueries({ queryKey: ["notificationList"] })
},
});
})
return (
<div
className="p-4 rounded-xl border-[0.5px] transition-all"
<button
type="button"
className="w-full rounded-2xl border text-left transition-all hover:-translate-y-0.5"
style={{
backgroundColor: notification.is_read ? "var(--surface-secondary)" : "var(--surface-tertiary)",
backgroundColor: notification.is_read
? "var(--surface-secondary)"
: "var(--surface-elevated)",
borderColor: "var(--border-subtle)",
opacity: notification.is_read ? 0.75 : 1,
opacity: notification.is_read ? 0.85 : 1,
}}
onClick={() => {
if (!notification.is_read) {
markReadMutation.mutate();
}
if (!notification.is_read) markReadMutation.mutate()
}}
>
<div className="flex items-start gap-3">
<div
className="mt-0.5 w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: notification.is_read ? "var(--surface-ground)" : "var(--accent)" }}
>
{notification.is_read ? (
<MailOpen className="w-4 h-4" style={{ color: "var(--text-muted)" }} />
) : (
<Mail className="w-4 h-4 text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[12px] font-medium" style={{ color: "var(--text-primary)" }}>
{NOTIFICATION_TYPE_LABELS[notification.notification_type] || notification.notification_type}
</span>
{!notification.is_read && (
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: "var(--accent)" }} />
<div className="p-4">
<div className="flex items-start gap-3">
<div
className="flex size-8 shrink-0 items-center justify-center rounded-xl"
style={{
backgroundColor: notification.is_read
? "var(--surface-ground)"
: "var(--accent)",
}}
>
{notification.is_read ? (
<MailOpen
className="size-4"
style={{ color: "var(--text-muted)" }}
/>
) : (
<Mail className="size-4 text-white" />
)}
<span className="text-[11px] ml-auto shrink-0" style={{ color: "var(--text-tertiary)" }}>
{new Date(notification.created_at).toLocaleString()}
</span>
</div>
<p className="text-[13px] font-medium truncate" style={{ color: "var(--text-primary)" }}>
{notification.title}
</p>
{notification.content && (
<p className="text-[12px] mt-1 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
{notification.content}
</p>
)}
{(notification.room || notification.project) && (
<div className="flex items-center gap-2 mt-2 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
{notification.project && <span>Project: {notification.project}</span>}
{notification.room && <span>Room: {notification.room}</span>}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span
className="text-[12px] font-medium"
style={{ color: "var(--text-primary)" }}
>
{NOTIFICATION_TYPE_LABELS[notification.notification_type] ||
notification.notification_type}
</span>
{!notification.is_read && (
<span
className="size-1.5 rounded-full"
style={{ backgroundColor: "var(--accent)" }}
/>
)}
<span
className="ml-auto text-[11px]"
style={{ color: "var(--text-tertiary)" }}
>
{new Date(notification.created_at).toLocaleString()}
</span>
</div>
)}
<p
className="mt-1 truncate text-[13px] font-medium"
style={{ color: "var(--text-primary)" }}
>
{notification.title}
</p>
{notification.content && (
<p
className="mt-1 line-clamp-2 text-[12px]"
style={{ color: "var(--text-secondary)" }}
>
{notification.content}
</p>
)}
{(notification.room || notification.project) && (
<div className="mt-3 flex flex-wrap gap-2">
{notification.project && (
<Badge variant="outline">{`Project: ${notification.project}`}</Badge>
)}
{notification.room && (
<Badge variant="outline">{`Room: ${notification.room}`}</Badge>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
</button>
)
}
export function NotificationList() {
const queryClient = useQueryClient();
const [onlyUnread, setOnlyUnread] = useState(false);
const queryClient = useQueryClient()
const [onlyUnread, setOnlyUnread] = useState(false)
const { data, isLoading, isError } = useQuery({
queryKey: ["notificationList", onlyUnread],
queryFn: () => notificationList({ only_unread: onlyUnread || undefined, limit: 50 }),
});
queryFn: () =>
notificationList({ only_unread: onlyUnread || undefined, limit: 50 }),
})
const markAllReadMutation = useMutation({
mutationFn: () => notificationMarkAllRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationList"] });
queryClient.invalidateQueries({ queryKey: ["notificationList"] })
},
});
})
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: "var(--accent)" }} />
<Loader2
className="size-6 animate-spin"
style={{ color: "var(--accent)" }}
/>
</div>
);
)
}
if (isError) {
return (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
<p>Failed to load notifications</p>
</div>
);
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
<EmptyHeader>
<EmptyMedia variant="icon">
<Bell />
</EmptyMedia>
<EmptyTitle></EmptyTitle>
<EmptyDescription></EmptyDescription>
</EmptyHeader>
</Empty>
)
}
const notifications = data?.data?.data?.notifications ?? [];
const unreadCount = data?.data?.data?.unread_count ?? 0;
const notifications = data?.data?.data?.notifications ?? []
const unreadCount = data?.data?.data?.unread_count ?? 0
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4" style={{ color: "var(--text-tertiary)" }} />
<span className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
{unreadCount > 0 ? `${unreadCount} unread` : "All read"}
</span>
</div>
<div className="flex items-center gap-2">
<button
className="text-[12px] px-2.5 py-1 rounded-md transition-colors"
style={{
color: onlyUnread ? "var(--accent)" : "var(--text-secondary)",
backgroundColor: onlyUnread ? "color-mix(in srgb, var(--accent) 10%, transparent)" : "transparent",
}}
onClick={() => setOnlyUnread(!onlyUnread)}
>
Unread only
</button>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="text-[12px] h-7 gap-1"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-4">
<div className="flex items-center gap-2">
<Bell
className="size-4"
style={{ color: "var(--text-tertiary)" }}
/>
<span
className="text-[13px]"
style={{ color: "var(--text-secondary)" }}
>
<CheckCheck className="w-3.5 h-3.5" />
Mark all read
</Button>
)}
</div>
</div>
{unreadCount > 0 ? `${unreadCount} unread` : "All read"}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-full px-3 py-1.5 text-[12px] transition-colors"
style={{
color: onlyUnread ? "var(--accent)" : "var(--text-secondary)",
backgroundColor: onlyUnread
? "color-mix(in srgb, var(--accent) 10%, transparent)"
: "transparent",
}}
onClick={() => setOnlyUnread(!onlyUnread)}
>
Unread only
</button>
{unreadCount > 0 && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 rounded-full text-[12px]"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
>
<CheckCheck className="size-3.5" />
Mark all read
</Button>
)}
</div>
</CardContent>
</Card>
{notifications.length === 0 ? (
<div className="text-center py-16" style={{ color: "var(--text-muted)" }}>
<Bell className="w-10 h-10 mx-auto mb-3" style={{ color: "var(--text-tertiary)" }} />
<p className="text-[14px] font-medium">No notifications</p>
<p className="text-[12px] mt-1">
{onlyUnread ? "No unread notifications" : "You're all caught up"}
</p>
</div>
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)] py-14">
<EmptyHeader>
<EmptyMedia variant="icon">
<Bell />
</EmptyMedia>
<EmptyTitle></EmptyTitle>
<EmptyDescription>
{onlyUnread ? "没有未读通知" : "你已经查看完了"}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="space-y-2">
{notifications.map((n) => (
@ -168,5 +239,5 @@ export function NotificationList() {
</div>
)}
</div>
);
)
}

View File

@ -1,156 +1,234 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Link as LinkIcon, Building2, Calendar } from "lucide-react";
import type { UserInfoExternal } from "@/client/model";
import { format } from "date-fns";
import { Skeleton } from "@/components/ui/skeleton";
import { useUserFollowerCountQuery, useUserStarsQuery, useFollowMutation, useUnfollowMutation } from "@/hooks/useUserQuery";
import { t } from "@/i18n/T";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Link as LinkIcon, Building2, Calendar } from "lucide-react"
import type { UserInfoExternal } from "@/client/model"
import { format } from "date-fns"
import { Skeleton } from "@/components/ui/skeleton"
import {
useUserFollowerCountQuery,
useUserStarsQuery,
useFollowMutation,
useUnfollowMutation,
} from "@/hooks/useUserQuery"
import { t } from "@/i18n/T"
interface ProfileHeaderProps {
user: UserInfoExternal | null | undefined;
isMe: boolean;
isLoading?: boolean;
starsCount?: number;
followerCount?: number;
user: UserInfoExternal | null | undefined
isMe: boolean
isLoading?: boolean
starsCount?: number
followerCount?: number
}
export function ProfileHeader({ user, isMe, isLoading, starsCount: starsCountProp, followerCount: followerCountProp }: ProfileHeaderProps) {
const { data: starsData } = useUserStarsQuery(user?.username || "");
const { data: followerCountApi } = useUserFollowerCountQuery(user?.username || "");
const followMutation = useFollowMutation();
const unfollowMutation = useUnfollowMutation();
function StatBlock({
value,
label,
}: {
value: string | number
label: string
}) {
return (
<div className="rounded-xl bg-[var(--surface-ground)] px-3 py-2 text-center ring-1 ring-[var(--border-subtle)]">
<div
className="text-[15px] font-semibold"
style={{ color: "var(--text-primary)" }}
>
{value}
</div>
<div
className="text-[10px] tracking-[0.22em] uppercase"
style={{ color: "var(--text-tertiary)" }}
>
{label}
</div>
</div>
)
}
const starsCount = starsCountProp ?? starsData?.total ?? 0;
const followerCount = followerCountProp ?? followerCountApi ?? 0;
export function ProfileHeader({
user,
isMe,
isLoading,
starsCount: starsCountProp,
followerCount: followerCountProp,
}: ProfileHeaderProps) {
const { data: starsData } = useUserStarsQuery(user?.username || "")
const { data: followerCountApi } = useUserFollowerCountQuery(
user?.username || ""
)
const followMutation = useFollowMutation()
const unfollowMutation = useUnfollowMutation()
const starsCount = starsCountProp ?? starsData?.total ?? 0
const followerCount = followerCountProp ?? followerCountApi ?? 0
const handleFollow = async () => {
if (!user) return;
if (!user) return
try {
await followMutation.mutateAsync(user.username);
await followMutation.mutateAsync(user.username)
} catch (err) {
console.error("Follow failed:", err);
console.error("Follow failed:", err)
}
};
}
const handleUnfollow = async () => {
if (!user) return;
if (!user) return
try {
await unfollowMutation.mutateAsync(user.username);
await unfollowMutation.mutateAsync(user.username)
} catch (err) {
console.error("Unfollow failed:", err);
console.error("Unfollow failed:", err)
}
};
}
if (isLoading || !user) {
return (
<div className="rounded-xl p-6 mb-6 border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}>
<div className="flex flex-col md:flex-row gap-6 items-start">
<Skeleton className="w-16 h-16 md:w-20 md:h-20 rounded-xl" />
<div className="flex-1 min-w-0 space-y-4">
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex gap-6">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex gap-8 pt-4 border-t" style={{ borderColor: "var(--border-subtle)" }}>
{[...Array(4)].map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-5 w-10 mx-auto" />
<Skeleton className="h-3 w-16 mx-auto" />
</div>
))}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col gap-6 md:flex-row md:items-start">
<Skeleton className="size-20 rounded-2xl" />
<div className="min-w-0 flex-1 space-y-4">
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex flex-wrap gap-3">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-28" />
</div>
<Separator />
<div className="grid gap-3 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-16 rounded-xl" />
))}
</div>
</div>
</div>
</div>
</div>
);
</CardContent>
</Card>
)
}
return (
<div className="rounded-xl p-6 mb-6 border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}>
<div className="flex flex-col md:flex-row gap-6 items-start">
<Avatar className="w-16 h-16 md:w-20 md:h-20 rounded-xl border-[0.5px] shadow-sm" style={{ borderColor: "var(--border-subtle)" }}>
<AvatarImage src={user.avatar_url || undefined} alt={user.username} />
<AvatarFallback className="text-2xl rounded-xl font-medium" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
{user.username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center justify-between gap-4 mb-1">
<div>
<h1 className="text-xl font-semibold" style={{ color: "var(--text-primary)" }}>
{user.display_name || user.username}
</h1>
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
@{user.username}
</p>
</div>
{!isMe && (
<Button
variant={user.is_subscribe ? "outline" : "default"}
className={user.is_subscribe ? "" : "text-[var(--accent-fg)]"}
style={user.is_subscribe ? {} : { backgroundColor: "var(--accent)" }}
onClick={user.is_subscribe ? handleUnfollow : handleFollow}
disabled={followMutation.isPending || unfollowMutation.isPending}
<Card className="mb-6">
<CardHeader className="gap-4">
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-4">
<Avatar className="size-20 rounded-2xl ring-1 ring-[var(--border-subtle)]">
<AvatarImage
src={user.avatar_url || undefined}
alt={user.username}
/>
<AvatarFallback
className="rounded-2xl text-2xl font-medium"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
>
{followMutation.isPending || unfollowMutation.isPending
? "..."
: user.is_subscribe ? t("me.profile.unfollow") : t("me.profile.follow")}
</Button>
)}
{user.username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<CardTitle className="text-[22px] leading-tight">
{user.display_name || user.username}
</CardTitle>
{isMe && <Badge variant="secondary">Me</Badge>}
</div>
<CardDescription className="mt-1 text-[13px]">
@{user.username}
</CardDescription>
<div
className="mt-4 flex flex-wrap gap-2 text-[12px]"
style={{ color: "var(--text-tertiary)" }}
>
<div className="flex items-center gap-1.5 rounded-full bg-[var(--surface-ground)] px-3 py-1 ring-1 ring-[var(--border-subtle)]">
<Calendar className="size-3.5" />
<span>
{t("me.profile.joined")}{" "}
{format(new Date(user.created_at), "MMM yyyy")}
</span>
</div>
{user.organization && (
<div className="flex items-center gap-1.5 rounded-full bg-[var(--surface-ground)] px-3 py-1 ring-1 ring-[var(--border-subtle)]">
<Building2 className="size-3.5" />
<span>{user.organization}</span>
</div>
)}
{user.website_url && (
<a
href={
user.website_url.startsWith("http")
? user.website_url
: `https://${user.website_url}`
}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full bg-[var(--surface-ground)] px-3 py-1 ring-1 ring-[var(--border-subtle)] transition-colors hover:text-[var(--text-primary)]"
style={{ color: "var(--text-info)" }}
>
<LinkIcon className="size-3.5" />
<span className="truncate">
{user.website_url.replace(/^https?:\/\//, "")}
</span>
</a>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-2 mt-3 text-[12px]" style={{ color: "var(--text-tertiary)" }}>
<div className="flex items-center gap-1.5">
<Calendar className="w-[14px] h-[14px]" />
<span>{t("me.profile.joined")} {format(new Date(user.user_uid === "00000000-0000-0000-0000-000000000000" ? new Date() : new Date()), "MMM yyyy")}</span>
</div>
{user.organization && (
<div className="flex items-center gap-1.5">
<Building2 className="w-[14px] h-[14px]" />
<span>{user.organization}</span>
</div>
)}
{user.website_url && (
<div className="flex items-center gap-1.5">
<LinkIcon className="w-[14px] h-[14px]" />
<a
href={user.website_url.startsWith("http") ? user.website_url : `https://${user.website_url}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
style={{ color: "var(--text-info)" }}
>
{user.website_url.replace(/^https?:\/\//, "")}
</a>
</div>
)}
</div>
<div className="flex gap-8 mt-4 pt-4 border-t" style={{ borderColor: "var(--border-subtle)" }}>
<div className="text-center">
<p className="text-[15px] font-semibold" style={{ color: "var(--text-primary)" }}>{user.total_projects}</p>
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>{t("me.profile.projects")}</p>
</div>
<div className="text-center">
<p className="text-[15px] font-semibold" style={{ color: "var(--text-primary)" }}>{user.total_repos}</p>
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>{t("me.profile.repos")}</p>
</div>
<div className="text-center">
<p className="text-[15px] font-semibold" style={{ color: "var(--text-primary)" }}>{starsCount}</p>
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>{t("me.profile.stars")}</p>
</div>
<div className="text-center cursor-pointer hover:opacity-70 transition-opacity" onClick={() => window.location.href = "/me/followers"}>
<p className="text-[15px] font-semibold" style={{ color: "var(--text-primary)" }}>{followerCount}</p>
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: "var(--text-tertiary)" }}>{t("me.profile.followers")}</p>
</div>
</div>
{!isMe && (
<Button
variant={user.is_subscribe ? "outline" : "default"}
className={user.is_subscribe ? "" : "text-[var(--accent-fg)]"}
style={
user.is_subscribe ? {} : { backgroundColor: "var(--accent)" }
}
onClick={user.is_subscribe ? handleUnfollow : handleFollow}
disabled={followMutation.isPending || unfollowMutation.isPending}
>
{followMutation.isPending || unfollowMutation.isPending
? "..."
: user.is_subscribe
? t("me.profile.unfollow")
: t("me.profile.follow")}
</Button>
)}
</div>
</div>
</div>
);
</CardHeader>
<CardContent className="pb-6">
<Separator />
<div className="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatBlock
value={user.total_projects}
label={t("me.profile.projects")}
/>
<StatBlock value={user.total_repos} label={t("me.profile.repos")} />
<StatBlock value={starsCount} label={t("me.profile.stars")} />
<button
type="button"
onClick={() => (window.location.href = "/me/followers")}
className="rounded-xl text-left transition-transform hover:-translate-y-0.5"
>
<StatBlock
value={followerCount}
label={t("me.profile.followers")}
/>
</button>
</div>
</CardContent>
</Card>
)
}

View File

@ -1,83 +1,129 @@
import { useNavigate } from "react-router-dom";
import { Users, Lock } from "lucide-react";
import type { UserProjectInfo } from "@/client/model";
import { Skeleton } from "@/components/ui/skeleton";
import { useNavigate } from "react-router-dom"
import { Users, Lock } from "lucide-react"
import type { UserProjectInfo } from "@/client/model"
import { Card } from "@/components/ui/card"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
import { t } from "@/i18n/T"
interface ProjectListProps {
projects: UserProjectInfo[];
isLoading?: boolean;
projects: UserProjectInfo[]
isLoading?: boolean
}
export function ProjectList({ projects, isLoading }: ProjectListProps) {
const navigate = useNavigate();
const navigate = useNavigate()
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid gap-3 md:grid-cols-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="p-3 rounded-xl border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}>
<div className="flex items-center gap-3 mb-2">
<Skeleton className="w-8 h-8 rounded-lg" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-2 w-16" />
<Card key={i} size="sm">
<div className="space-y-3 px-3">
<div className="flex items-center gap-3">
<Skeleton className="size-8 rounded-lg" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-2 w-16" />
</div>
</div>
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-2/3" />
<div className="flex items-center gap-4 pt-1">
<Skeleton className="h-2.5 w-20" />
<Skeleton className="h-2.5 w-16" />
</div>
</div>
<Skeleton className="h-3 w-full mb-1" />
<Skeleton className="h-3 w-2/3" />
<div className="flex items-center gap-4 mt-2">
<Skeleton className="h-2 w-20" />
<Skeleton className="h-2 w-16" />
</div>
</div>
</Card>
))}
</div>
);
)
}
if (projects.length === 0) {
return (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
<p>No projects found</p>
</div>
);
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
<EmptyHeader>
<EmptyMedia variant="icon">
<Users />
</EmptyMedia>
<EmptyTitle>{t("me.top_projects")}</EmptyTitle>
<EmptyDescription></EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid gap-3 md:grid-cols-2">
{projects.map((project) => (
<div
<Card
key={project.uid}
className="p-3 rounded-xl border-[0.5px] transition-all cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}
size="sm"
className="cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-[var(--accent)]/20"
onClick={() => navigate(`/${project.name}`)}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-[11px] font-bold shrink-0" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
{project.display_name[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="px-3">
<div className="flex items-start gap-3">
<div
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
>
{project.display_name[0].toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-[12px] font-semibold truncate" style={{ color: "var(--text-primary)" }}>
{project.display_name}
</h3>
{!project.is_public && <Lock className="w-3 h-3" style={{ color: "var(--text-tertiary)" }} />}
<h3
className="truncate text-[13px] font-semibold"
style={{ color: "var(--text-primary)" }}
>
{project.display_name}
</h3>
{!project.is_public && (
<Lock
className="size-3.5"
style={{ color: "var(--text-tertiary)" }}
/>
)}
</div>
<p className="text-[11px]" style={{ color: "var(--text-tertiary)" }}>@{project.name}</p>
<p
className="text-[11px]"
style={{ color: "var(--text-tertiary)" }}
>
@{project.name}
</p>
</div>
</div>
<p
className="mt-3 line-clamp-2 min-h-[2.5rem] text-[12px]"
style={{ color: "var(--text-secondary)" }}
>
{project.description || "暂无描述"}
</p>
<div
className="mt-3 flex items-center gap-4 text-[11px]"
style={{ color: "var(--text-tertiary)" }}
>
<div className="flex items-center gap-1">
<Users className="size-3" />
<span>{project.member_count} members</span>
</div>
<span>{new Date(project.created_at).toLocaleDateString()}</span>
</div>
</div>
<p className="text-[11px] line-clamp-2 min-h-[2.5rem]" style={{ color: "var(--text-secondary)" }}>
{project.description || "No description provided"}
</p>
<div className="flex items-center gap-4 mt-2 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
<span>{project.member_count} members</span>
</div>
<span>{new Date(project.created_at).toLocaleDateString()}</span>
</div>
</div>
</Card>
))}
</div>
);
)
}

View File

@ -1,75 +1,121 @@
import { useNavigate } from "react-router-dom";
import { GitBranch, Lock } from "lucide-react";
import type { UserRepoInfo } from "@/client/model";
import { Skeleton } from "@/components/ui/skeleton";
import { useNavigate } from "react-router-dom"
import { GitBranch, Lock } from "lucide-react"
import type { UserRepoInfo } from "@/client/model"
import { Card } from "@/components/ui/card"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
import { t } from "@/i18n/T"
interface RepoListProps {
repos: UserRepoInfo[];
isLoading?: boolean;
repos: UserRepoInfo[]
isLoading?: boolean
}
export function RepoList({ repos, isLoading }: RepoListProps) {
const navigate = useNavigate();
const navigate = useNavigate()
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid gap-3 md:grid-cols-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="p-4 rounded-xl border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}>
<div className="flex items-center gap-2 mb-2">
<Skeleton className="w-4 h-4 rounded-full" />
<Skeleton className="h-4 w-32" />
<Card key={i} size="sm">
<div className="space-y-3 px-3">
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-2/3" />
<div className="flex items-center gap-4 pt-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-3 w-full mb-1" />
<Skeleton className="h-3 w-2/3" />
<div className="flex items-center gap-4 mt-4">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</Card>
))}
</div>
);
)
}
if (repos.length === 0) {
return (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
<p>No repositories found</p>
</div>
);
<Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
<EmptyHeader>
<EmptyMedia variant="icon">
<GitBranch />
</EmptyMedia>
<EmptyTitle>{t("me.recent_repos")}</EmptyTitle>
<EmptyDescription></EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid gap-3 md:grid-cols-2">
{repos.map((repo) => (
<div
<Card
key={repo.uid}
className="p-4 rounded-xl border-[0.5px] transition-all cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}
onClick={() => navigate(`/${repo.project_name}/repo/${repo.repo_name}`)}
size="sm"
className="cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-[var(--accent)]/20"
onClick={() =>
navigate(`/${repo.project_name}/repo/${repo.repo_name}`)
}
>
<div className="flex items-center gap-2 mb-1.5">
<GitBranch className="w-[14px] h-[14px]" style={{ color: "var(--text-tertiary)" }} />
<h3 className="text-[13px] font-semibold truncate" style={{ color: "var(--text-info)" }}>
{repo.repo_name}
</h3>
{repo.is_private && (
<Lock className="w-3 h-3" style={{ color: "var(--text-tertiary)" }} />
)}
</div>
<p className="text-[12px] line-clamp-2 min-h-[2.5rem]" style={{ color: "var(--text-secondary)" }}>
{repo.description || "No description provided"}
</p>
<div className="flex items-center gap-4 mt-3 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: "var(--accent)" }} />
<span className="font-medium" style={{ color: "var(--text-secondary)" }}>{repo.default_branch}</span>
<div className="px-3">
<div className="flex items-center gap-2">
<GitBranch
className="size-4"
style={{ color: "var(--text-tertiary)" }}
/>
<h3
className="truncate text-[13px] font-semibold"
style={{ color: "var(--text-info)" }}
>
{repo.repo_name}
</h3>
{repo.is_private && (
<Lock
className="size-3.5"
style={{ color: "var(--text-tertiary)" }}
/>
)}
</div>
<p
className="mt-3 line-clamp-2 min-h-[2.5rem] text-[12px]"
style={{ color: "var(--text-secondary)" }}
>
{repo.description || "暂无描述"}
</p>
<div
className="mt-3 flex items-center gap-4 text-[11px]"
style={{ color: "var(--text-tertiary)" }}
>
<div className="flex items-center gap-1.5">
<div
className="size-2 rounded-full"
style={{ backgroundColor: "var(--accent)" }}
/>
<span
className="font-medium"
style={{ color: "var(--text-secondary)" }}
>
{repo.default_branch}
</span>
</div>
<span>
Updated {new Date(repo.updated_at).toLocaleDateString()}
</span>
</div>
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
</div>
</div>
</Card>
))}
</div>
);
)
}