gitdataai/src/page/workspace/repo/layout.tsx

210 lines
8.5 KiB
TypeScript

import { useParams } from "react-router";
import { NavLink, Outlet } from "react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/client";
import { Lock, Globe, Archive, GitFork, Star, Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
function formatSize(bytes: number) {
if (bytes === 0) return "Empty";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(date: string) {
const now = new Date();
const d = new Date(date);
const diff = now.getTime() - d.getTime();
const days = Math.floor(diff / 86400000);
if (days < 1) return "today";
if (days === 1) return "1 day ago";
if (days < 30) return `${days} days ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
const tabs = [
{ label: "Code", to: "code" },
{ label: "Commits", to: "commits" },
{ label: "Branches", to: "branches" },
{ label: "Tags", to: "tags" },
{ label: "Pull Requests", to: "pulls" },
{ label: "Contributors", to: "contributors" },
{ label: "Webhooks", to: "webhooks" },
{ label: "Settings", to: "settings" },
];
export default function RepoLayout() {
const { projectName = "", repoName = "" } = useParams();
const qc = useQueryClient();
const { data: repo, isLoading } = useQuery({
queryKey: ["repo", projectName, repoName],
queryFn: async () => {
const res = await client.gitGetRepo(projectName, repoName);
return res.data;
},
enabled: Boolean(projectName) && Boolean(repoName),
retry: false,
});
const { data: starData } = useQuery({
queryKey: ["repo", projectName, repoName, "star"],
queryFn: async () => {
const res: any = await client.gitStarStatus(projectName, repoName);
return res.data as { starred: boolean; count: number };
},
enabled: Boolean(projectName) && Boolean(repoName),
retry: false,
});
const starRepo = useMutation({
mutationFn: async () => {
if (starData?.starred) { await client.gitUnstarRepo(projectName, repoName); }
else { await client.gitStarRepo(projectName, repoName); }
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["repo", projectName, repoName, "star"] }),
});
const { data: watchData } = useQuery({
queryKey: ["repo", projectName, repoName, "watch"],
queryFn: async () => {
const res: any = await client.gitWatchStatus(projectName, repoName);
return res.data as { watching: boolean; count: number };
},
enabled: Boolean(projectName) && Boolean(repoName),
retry: false,
});
const watchRepo = useMutation({
mutationFn: async () => {
if (watchData?.watching) { await client.gitUnwatchRepo(projectName, repoName); }
else { await client.gitWatchRepo(projectName, repoName, {} as any); }
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["repo", projectName, repoName, "watch"] }),
});
// Check if README exists
const { data: readme } = useQuery({
queryKey: ["repo", projectName, repoName, "readme"],
queryFn: async () => {
const res = await client.gitGetReadme(projectName, repoName);
return res.data;
},
enabled: Boolean(projectName) && Boolean(repoName),
retry: false,
});
const allTabs = [
...(readme?.html ? [{ label: "README", to: "readme" }] : []),
...tabs,
];
if (isLoading) {
return (
<div className="px-8 py-10 space-y-3">
<div className="h-7 animate-pulse rounded bg-muted/50 w-64" />
<div className="h-5 animate-pulse rounded bg-muted/50 w-80" />
<div className="h-4 animate-pulse rounded bg-muted/50 w-48" />
</div>
);
}
if (!repo) {
return (
<div className="flex flex-col items-center justify-center px-8 py-20 text-center">
<div className="grid size-16 place-items-center rounded-2xl bg-muted/40">
<GitFork className="size-7 text-muted-foreground/40" />
</div>
<h2 className="mt-5 font-heading text-lg font-semibold text-foreground">Repository not found</h2>
<p className="mt-2 max-w-sm text-[13px] text-muted-foreground">
This repository doesn't exist or you don't have permission to view it.
</p>
</div>
);
}
return (
<div className="mx-auto max-w-6xl px-6 py-8 lg:px-8 lg:py-10">
{/* Header */}
<div className="flex items-center gap-2 flex-wrap">
<h1 className="font-heading text-lg font-bold text-foreground">{repo.name}</h1>
{repo.visibility === "private" ? (
<span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
<Lock className="size-2.5" />Private
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
<Globe className="size-2.5" />Public
</span>
)}
{repo.is_archived && (
<span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
<Archive className="size-2.5" />Archived
</span>
)}
{repo.is_mirror && (
<span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">Mirror</span>
)}
{repo.is_template && (
<span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">Template</span>
)}
</div>
{repo.description && (
<p className="mt-1 text-[13px] text-muted-foreground max-w-2xl">{repo.description}</p>
)}
<div className="mt-2 flex items-center gap-3 font-mono text-[11px] text-muted-foreground flex-wrap">
<span><span className="text-foreground/60">default branch:</span> {repo.default_branch}</span>
<span className="text-muted-foreground/30">|</span>
<span>{formatSize(repo.size_bytes)}</span>
<span className="text-muted-foreground/30">|</span>
<span>created {formatDate(repo.created_at)}</span>
<div className="flex items-center gap-1.5 ml-2">
<button
className={cn("inline-flex items-center gap-1 rounded-sm border px-2 py-1 text-[11px] transition-colors", starData?.starred ? "border-primary/40 bg-primary/5 text-primary" : "border-border text-muted-foreground hover:text-foreground")}
disabled={starRepo.isPending}
onClick={() => starRepo.mutate()}
>
<Star className={cn("size-3", starData?.starred && "fill-primary")} />
{starData?.starred ? "Starred" : "Star"}
{typeof starData?.count === "number" && <span className="font-mono text-[10px]">{starData.count}</span>}
</button>
<button
className={cn("inline-flex items-center gap-1 rounded-sm border px-2 py-1 text-[11px] transition-colors", watchData?.watching ? "border-primary/40 bg-primary/5 text-primary" : "border-border text-muted-foreground hover:text-foreground")}
disabled={watchRepo.isPending}
onClick={() => watchRepo.mutate()}
>
{watchData?.watching ? <Eye className="size-3" /> : <EyeOff className="size-3" />}
{watchData?.watching ? "Watching" : "Watch"}
{typeof watchData?.count === "number" && <span className="font-mono text-[10px]">{watchData.count}</span>}
</button>
</div>
</div>
{/* Tab nav */}
<nav className="mt-6 flex items-center gap-0 overflow-x-auto border-b border-border">
{allTabs.map((tab) => (
<NavLink
className={({ isActive }) => cn(
"inline-flex shrink-0 items-center px-3 h-8 text-[13px] font-medium border-b-2 border-transparent transition-colors -mb-px",
isActive ? "text-foreground border-primary" : "text-muted-foreground hover:text-foreground hover:border-muted-foreground/30",
)}
end
key={tab.to}
to={tab.to}
>
{tab.label}
</NavLink>
))}
</nav>
{/* Content */}
<div className="mt-6">
<Outlet />
</div>
</div>
);
}