110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
import { gitContributors } from "@/client";
|
|
import type { ContributorStats } from "@/client";
|
|
import { RepoHeader } from "@/components/repository/header";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Hash, Loader2, Mail, Calendar } from "lucide-react";
|
|
import { useParams } from "react-router-dom";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
|
|
function formatDate(ts: number | null | undefined): string {
|
|
if (!ts) return "—";
|
|
return new Date(ts * 1000).toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
function ContributorCard({ c }: { c: ContributorStats }) {
|
|
const initials = c.name
|
|
? c.name
|
|
.split(/\s+/)
|
|
.slice(0, 2)
|
|
.map((p) => p[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
: c.email[0].toUpperCase();
|
|
|
|
return (
|
|
<div className="flex items-start gap-3 border rounded-lg px-4 py-3 hover:bg-muted/50 transition-colors">
|
|
<Avatar className="h-9 w-9 flex-shrink-0">
|
|
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
|
</Avatar>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{c.name || "—"}</p>
|
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
<Mail className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
|
<span className="text-xs text-muted-foreground truncate">{c.email}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
|
<span className="text-sm font-semibold tabular-nums">{c.commits.toLocaleString()}</span>
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Calendar className="h-3 w-3" />
|
|
{formatDate(c.last_commit_at)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const RepoContributors = () => {
|
|
const { namespace, repoName } = useParams<{ namespace: string; repoName: string }>();
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["repo-contributors", namespace, repoName],
|
|
queryFn: async () => {
|
|
if (!namespace || !repoName) return null;
|
|
const resp = await gitContributors({ path: { namespace, repo: repoName } });
|
|
return resp.data?.data ?? null;
|
|
},
|
|
enabled: !!namespace && !!repoName,
|
|
staleTime: 60 * 1000,
|
|
});
|
|
|
|
const contributors: ContributorStats[] = data?.contributors ?? [];
|
|
const total: number = data?.total ?? 0;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<Loader2 className="h-6 w-6 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="p-6 pb-0 max-w-5xl mx-auto space-y-6">
|
|
<RepoHeader />
|
|
</div>
|
|
<div className="p-6 pt-0 max-w-5xl mx-auto space-y-4">
|
|
<div className="flex items-center justify-between py-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">Contributors</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{total} contributor{total !== 1 ? "s" : ""} — sorted by commit count
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{contributors.length === 0 ? (
|
|
<div className="p-12 text-center text-muted-foreground border rounded-lg">
|
|
<Hash className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
|
<p className="font-medium">No contributors found</p>
|
|
<p className="text-sm mt-1">This repository has no commits yet.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{contributors.map((c) => (
|
|
<ContributorCard key={c.email} c={c} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|