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 { 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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user