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 { Outlet, useLocation } from "react-router-dom"
import { useState } from "react"; import { useState } from "react"
import { PanelLeftOpen } from "lucide-react"; import { PanelLeftOpen } from "lucide-react"
import { ServerIconRail } from "@/components/layout/ServerIconRail"; import { ServerIconRail } from "@/components/layout/ServerIconRail"
import { MeSidebar } from "./components/MeSidebar"; import { MeSidebar } from "./components/MeSidebar"
import { Header } from "@/components/layout/Header"; import { Header } from "@/components/layout/Header"
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile"
export function MeLayout() { export function MeLayout() {
const isMobile = useIsMobile(); const isMobile = useIsMobile()
const location = useLocation(); const location = useLocation()
const isExplore = location.pathname === "/explore"; const isExplore = location.pathname === "/explore"
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
return ( 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 />} {!isMobile && <ServerIconRail />}
{!isExplore && ( {!isExplore && (
<div className="relative flex shrink-0"> <div className="relative flex shrink-0">
<div <div
style={{ style={{
width: isSidebarCollapsed ? 0 : 220, width: isSidebarCollapsed ? 0 : 220,
transition: "width 0.2s ease", transition: "width 0.2s ease",
overflow: "hidden", overflow: "hidden",
backgroundColor: "var(--surface-sidebar)", backgroundColor: "var(--surface-sidebar)",
}} }}
> >
{!isSidebarCollapsed && ( {!isSidebarCollapsed && (
<MeSidebar onCollapse={() => setIsSidebarCollapsed(true)} /> <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> </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 /> <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 /> <Outlet />
</main> </main>
</div> </div>
</div> </div>
); )
} }

View File

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

View File

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

View File

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

View File

@ -1,156 +1,234 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"
import { Link as LinkIcon, Building2, Calendar } from "lucide-react"; import { Button } from "@/components/ui/button"
import type { UserInfoExternal } from "@/client/model"; import {
import { format } from "date-fns"; Card,
import { Skeleton } from "@/components/ui/skeleton"; CardContent,
import { useUserFollowerCountQuery, useUserStarsQuery, useFollowMutation, useUnfollowMutation } from "@/hooks/useUserQuery"; CardDescription,
import { t } from "@/i18n/T"; 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 { interface ProfileHeaderProps {
user: UserInfoExternal | null | undefined; user: UserInfoExternal | null | undefined
isMe: boolean; isMe: boolean
isLoading?: boolean; isLoading?: boolean
starsCount?: number; starsCount?: number
followerCount?: number; followerCount?: number
} }
export function ProfileHeader({ user, isMe, isLoading, starsCount: starsCountProp, followerCount: followerCountProp }: ProfileHeaderProps) { function StatBlock({
const { data: starsData } = useUserStarsQuery(user?.username || ""); value,
const { data: followerCountApi } = useUserFollowerCountQuery(user?.username || ""); label,
const followMutation = useFollowMutation(); }: {
const unfollowMutation = useUnfollowMutation(); 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; export function ProfileHeader({
const followerCount = followerCountProp ?? followerCountApi ?? 0; 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 () => { const handleFollow = async () => {
if (!user) return; if (!user) return
try { try {
await followMutation.mutateAsync(user.username); await followMutation.mutateAsync(user.username)
} catch (err) { } catch (err) {
console.error("Follow failed:", err); console.error("Follow failed:", err)
} }
}; }
const handleUnfollow = async () => { const handleUnfollow = async () => {
if (!user) return; if (!user) return
try { try {
await unfollowMutation.mutateAsync(user.username); await unfollowMutation.mutateAsync(user.username)
} catch (err) { } catch (err) {
console.error("Unfollow failed:", err); console.error("Unfollow failed:", err)
} }
}; }
if (isLoading || !user) { if (isLoading || !user) {
return ( return (
<div className="rounded-xl p-6 mb-6 border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}> <Card className="mb-6">
<div className="flex flex-col md:flex-row gap-6 items-start"> <CardContent className="pt-6">
<Skeleton className="w-16 h-16 md:w-20 md:h-20 rounded-xl" /> <div className="flex flex-col gap-6 md:flex-row md:items-start">
<div className="flex-1 min-w-0 space-y-4"> <Skeleton className="size-20 rounded-2xl" />
<div className="space-y-2"> <div className="min-w-0 flex-1 space-y-4">
<Skeleton className="h-6 w-48" /> <div className="space-y-2">
<Skeleton className="h-4 w-32" /> <Skeleton className="h-6 w-48" />
</div> <Skeleton className="h-4 w-32" />
<div className="flex gap-6"> </div>
<Skeleton className="h-4 w-24" /> <div className="flex flex-wrap gap-3">
<Skeleton className="h-4 w-24" /> <Skeleton className="h-4 w-28" />
</div> <Skeleton className="h-4 w-28" />
<div className="flex gap-8 pt-4 border-t" style={{ borderColor: "var(--border-subtle)" }}> </div>
{[...Array(4)].map((_, i) => ( <Separator />
<div key={i} className="space-y-1"> <div className="grid gap-3 sm:grid-cols-4">
<Skeleton className="h-5 w-10 mx-auto" /> {[...Array(4)].map((_, i) => (
<Skeleton className="h-3 w-16 mx-auto" /> <Skeleton key={i} className="h-16 rounded-xl" />
</div> ))}
))} </div>
</div> </div>
</div> </div>
</div> </CardContent>
</div> </Card>
); )
} }
return ( return (
<div className="rounded-xl p-6 mb-6 border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}> <Card className="mb-6">
<div className="flex flex-col md:flex-row gap-6 items-start"> <CardHeader className="gap-4">
<Avatar className="w-16 h-16 md:w-20 md:h-20 rounded-xl border-[0.5px] shadow-sm" style={{ borderColor: "var(--border-subtle)" }}> <div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
<AvatarImage src={user.avatar_url || undefined} alt={user.username} /> <div className="flex min-w-0 flex-1 items-start gap-4">
<AvatarFallback className="text-2xl rounded-xl font-medium" style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}> <Avatar className="size-20 rounded-2xl ring-1 ring-[var(--border-subtle)]">
{user.username[0].toUpperCase()} <AvatarImage
</AvatarFallback> src={user.avatar_url || undefined}
</Avatar> alt={user.username}
/>
<div className="flex-1 min-w-0"> <AvatarFallback
<div className="flex flex-wrap items-center justify-between gap-4 mb-1"> className="rounded-2xl text-2xl font-medium"
<div> style={{
<h1 className="text-xl font-semibold" style={{ color: "var(--text-primary)" }}> backgroundColor: "var(--accent)",
{user.display_name || user.username} color: "var(--accent-fg)",
</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}
> >
{followMutation.isPending || unfollowMutation.isPending {user.username[0].toUpperCase()}
? "..." </AvatarFallback>
: user.is_subscribe ? t("me.profile.unfollow") : t("me.profile.follow")} </Avatar>
</Button>
)} <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>
<div className="flex flex-wrap gap-x-6 gap-y-2 mt-3 text-[12px]" style={{ color: "var(--text-tertiary)" }}> {!isMe && (
<div className="flex items-center gap-1.5"> <Button
<Calendar className="w-[14px] h-[14px]" /> variant={user.is_subscribe ? "outline" : "default"}
<span>{t("me.profile.joined")} {format(new Date(user.user_uid === "00000000-0000-0000-0000-000000000000" ? new Date() : new Date()), "MMM yyyy")}</span> className={user.is_subscribe ? "" : "text-[var(--accent-fg)]"}
</div> style={
{user.organization && ( user.is_subscribe ? {} : { backgroundColor: "var(--accent)" }
<div className="flex items-center gap-1.5"> }
<Building2 className="w-[14px] h-[14px]" /> onClick={user.is_subscribe ? handleUnfollow : handleFollow}
<span>{user.organization}</span> disabled={followMutation.isPending || unfollowMutation.isPending}
</div> >
)} {followMutation.isPending || unfollowMutation.isPending
{user.website_url && ( ? "..."
<div className="flex items-center gap-1.5"> : user.is_subscribe
<LinkIcon className="w-[14px] h-[14px]" /> ? t("me.profile.unfollow")
<a : t("me.profile.follow")}
href={user.website_url.startsWith("http") ? user.website_url : `https://${user.website_url}`} </Button>
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>
</div> </div>
</div> </CardHeader>
</div>
); <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 { useNavigate } from "react-router-dom"
import { Users, Lock } from "lucide-react"; import { Users, Lock } from "lucide-react"
import type { UserProjectInfo } from "@/client/model"; import type { UserProjectInfo } from "@/client/model"
import { Skeleton } from "@/components/ui/skeleton"; 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 { interface ProjectListProps {
projects: UserProjectInfo[]; projects: UserProjectInfo[]
isLoading?: boolean; isLoading?: boolean
} }
export function ProjectList({ projects, isLoading }: ProjectListProps) { export function ProjectList({ projects, isLoading }: ProjectListProps) {
const navigate = useNavigate(); const navigate = useNavigate()
if (isLoading) { if (isLoading) {
return ( 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) => ( {[...Array(4)].map((_, i) => (
<div key={i} className="p-3 rounded-xl border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}> <Card key={i} size="sm">
<div className="flex items-center gap-3 mb-2"> <div className="space-y-3 px-3">
<Skeleton className="w-8 h-8 rounded-lg" /> <div className="flex items-center gap-3">
<div className="flex-1 space-y-1.5"> <Skeleton className="size-8 rounded-lg" />
<Skeleton className="h-3 w-24" /> <div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-2 w-16" /> <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>
</div> </div>
<Skeleton className="h-3 w-full mb-1" /> </Card>
<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>
))} ))}
</div> </div>
); )
} }
if (projects.length === 0) { if (projects.length === 0) {
return ( return (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}> <Empty className="border-[var(--border-subtle)] bg-[var(--surface-secondary)]">
<p>No projects found</p> <EmptyHeader>
</div> <EmptyMedia variant="icon">
); <Users />
</EmptyMedia>
<EmptyTitle>{t("me.top_projects")}</EmptyTitle>
<EmptyDescription></EmptyDescription>
</EmptyHeader>
</Empty>
)
} }
return ( 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) => ( {projects.map((project) => (
<div <Card
key={project.uid} key={project.uid}
className="p-3 rounded-xl border-[0.5px] transition-all cursor-pointer hover:opacity-80" size="sm"
style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }} className="cursor-pointer transition-all hover:-translate-y-0.5 hover:ring-[var(--accent)]/20"
onClick={() => navigate(`/${project.name}`)} onClick={() => navigate(`/${project.name}`)}
> >
<div className="flex items-center gap-3 mb-2"> <div className="px-3">
<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)" }}> <div className="flex items-start gap-3">
{project.display_name[0].toUpperCase()} <div
</div> className="flex size-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold"
<div className="flex-1 min-w-0"> 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"> <div className="flex items-center gap-2">
<h3 className="text-[12px] font-semibold truncate" style={{ color: "var(--text-primary)" }}> <h3
{project.display_name} className="truncate text-[13px] font-semibold"
</h3> style={{ color: "var(--text-primary)" }}
{!project.is_public && <Lock className="w-3 h-3" style={{ color: "var(--text-tertiary)" }} />} >
{project.display_name}
</h3>
{!project.is_public && (
<Lock
className="size-3.5"
style={{ color: "var(--text-tertiary)" }}
/>
)}
</div> </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>
</div> </div>
<p className="text-[11px] line-clamp-2 min-h-[2.5rem]" style={{ color: "var(--text-secondary)" }}> </Card>
{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>
))} ))}
</div> </div>
); )
} }

View File

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