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:
parent
cab064f83f
commit
16739d3cf8
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
|
||||
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user