210 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
} |