mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-07 03:56:46 +03:00
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { useMemo, useRef } from "react";
|
|
import Fuse from "fuse.js";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import { GitFork, RefreshCw, RotateCcw } from "lucide-react";
|
|
import { SiGithub, SiGitea } from "react-icons/si";
|
|
import type { Repository } from "@/lib/db/schema";
|
|
import { Button } from "@/components/ui/button";
|
|
import { formatDate, getStatusColor } from "@/lib/utils";
|
|
import type { FilterParams } from "@/types/filter";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
|
|
|
interface RepositoryTableProps {
|
|
repositories: Repository[];
|
|
isLoading: boolean;
|
|
filter: FilterParams;
|
|
setFilter: (filter: FilterParams) => void;
|
|
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
|
|
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
|
|
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
|
|
loadingRepoIds: Set<string>;
|
|
}
|
|
|
|
export default function RepositoryTable({
|
|
repositories,
|
|
isLoading,
|
|
filter,
|
|
setFilter,
|
|
onMirror,
|
|
onSync,
|
|
onRetry,
|
|
loadingRepoIds,
|
|
}: RepositoryTableProps) {
|
|
const tableParentRef = useRef<HTMLDivElement>(null);
|
|
const { giteaConfig } = useGiteaConfig();
|
|
|
|
// Helper function to construct Gitea repository URL
|
|
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
|
if (!giteaConfig?.url) {
|
|
return null;
|
|
}
|
|
|
|
// Only provide Gitea links for repositories that have been or are being mirrored
|
|
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
|
|
if (!validStatuses.includes(repository.status)) {
|
|
return null;
|
|
}
|
|
|
|
// Use mirroredLocation if available, otherwise construct from repository data
|
|
let repoPath: string;
|
|
if (repository.mirroredLocation) {
|
|
repoPath = repository.mirroredLocation;
|
|
} else {
|
|
// Fallback: construct the path based on repository data
|
|
const owner = repository.organization || repository.owner;
|
|
repoPath = `${owner}/${repository.name}`;
|
|
}
|
|
|
|
// Ensure the base URL doesn't have a trailing slash
|
|
const baseUrl = giteaConfig.url.endsWith('/')
|
|
? giteaConfig.url.slice(0, -1)
|
|
: giteaConfig.url;
|
|
|
|
return `${baseUrl}/${repoPath}`;
|
|
};
|
|
|
|
const hasAnyFilter = Object.values(filter).some(
|
|
(val) => val?.toString().trim() !== ""
|
|
);
|
|
|
|
const filteredRepositories = useMemo(() => {
|
|
let result = repositories;
|
|
|
|
if (filter.status) {
|
|
result = result.filter((repo) => repo.status === filter.status);
|
|
}
|
|
|
|
if (filter.owner) {
|
|
result = result.filter((repo) => repo.owner === filter.owner);
|
|
}
|
|
|
|
if (filter.organization) {
|
|
result = result.filter(
|
|
(repo) => repo.organization === filter.organization
|
|
);
|
|
}
|
|
|
|
if (filter.searchTerm) {
|
|
const fuse = new Fuse(result, {
|
|
keys: ["name", "fullName", "owner", "organization"],
|
|
threshold: 0.3,
|
|
});
|
|
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
|
}
|
|
|
|
return result;
|
|
}, [repositories, filter]);
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: filteredRepositories.length,
|
|
getScrollElement: () => tableParentRef.current,
|
|
estimateSize: () => 65,
|
|
overscan: 5,
|
|
});
|
|
|
|
return isLoading ? (
|
|
<div className="border rounded-md">
|
|
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
|
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
|
Repository
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
Organization
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
Last Mirrored
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
Actions
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
|
Links
|
|
</div>
|
|
</div>
|
|
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="h-[65px] flex items-center justify-between border-b bg-transparent"
|
|
>
|
|
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
|
<Skeleton className="h-full w-full" />
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
<Skeleton className="h-full w-full" />
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
<Skeleton className="h-full w-full" />
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
<Skeleton className="h-full w-full" />
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
<Skeleton className="h-full w-full" />
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
<Skeleton className="h-full w-full" />
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
|
<Skeleton className="h-full w-full" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : filteredRepositories.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<GitFork className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-medium">No repositories found</h3>
|
|
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
|
{hasAnyFilter
|
|
? "Try adjusting your search or filter criteria."
|
|
: "Configure your GitHub connection to start mirroring repositories."}
|
|
</p>
|
|
{hasAnyFilter ? (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() =>
|
|
setFilter({
|
|
searchTerm: "",
|
|
status: "",
|
|
})
|
|
}
|
|
>
|
|
Clear Filters
|
|
</Button>
|
|
) : (
|
|
<Button asChild>
|
|
<a href="/config">Configure GitHub</a>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col border rounded-md">
|
|
{/* table header */}
|
|
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
|
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
|
Repository
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
Organization
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
Last Mirrored
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
|
Actions
|
|
</div>
|
|
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
|
Links
|
|
</div>
|
|
</div>
|
|
|
|
{/* table body wrapper (for a parent in virtualization) */}
|
|
<div
|
|
ref={tableParentRef}
|
|
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto" //adjusted height to account for status bar
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
|
const repo = filteredRepositories[virtualRow.index];
|
|
const isLoading = loadingRepoIds.has(repo.id ?? "");
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
ref={rowVirtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
width: "100%",
|
|
}}
|
|
data-index={virtualRow.index}
|
|
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
|
|
>
|
|
{/* Repository */}
|
|
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
|
|
<GitFork className="h-4 w-4 text-muted-foreground" />
|
|
<div>
|
|
<div className="font-medium">{repo.name}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{repo.fullName}
|
|
</div>
|
|
</div>
|
|
{repo.isPrivate && (
|
|
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
|
Private
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Owner */}
|
|
<div className="h-full p-3 flex items-center flex-[1]">
|
|
<p className="text-sm">{repo.owner}</p>
|
|
</div>
|
|
|
|
{/* Organization */}
|
|
<div className="h-full p-3 flex items-center flex-[1]">
|
|
<p className="text-sm"> {repo.organization || "-"}</p>
|
|
</div>
|
|
|
|
{/* Last Mirrored */}
|
|
<div className="h-full p-3 flex items-center flex-[1]">
|
|
<p className="text-sm">
|
|
{repo.lastMirrored
|
|
? formatDate(new Date(repo.lastMirrored))
|
|
: "Never"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
|
|
<div
|
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
|
repo.status
|
|
)}`}
|
|
/>
|
|
<span className="text-sm capitalize">{repo.status}</span>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="h-full p-3 flex items-center justify-start flex-[1]">
|
|
<RepoActionButton
|
|
repo={{ id: repo.id ?? "", status: repo.status }}
|
|
isLoading={isLoading}
|
|
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
|
|
onSync={() => onSync({ repoId: repo.id ?? "" })}
|
|
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Links */}
|
|
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
|
|
{(() => {
|
|
const giteaUrl = getGiteaRepoUrl(repo);
|
|
|
|
// Determine tooltip based on status and configuration
|
|
let tooltip: string;
|
|
if (!giteaConfig?.url) {
|
|
tooltip = "Gitea not configured";
|
|
} else if (repo.status === 'imported') {
|
|
tooltip = "Repository not yet mirrored to Gitea";
|
|
} else if (repo.status === 'failed') {
|
|
tooltip = "Repository mirroring failed";
|
|
} else if (repo.status === 'mirroring') {
|
|
tooltip = "Repository is being mirrored to Gitea";
|
|
} else if (giteaUrl) {
|
|
tooltip = "View on Gitea";
|
|
} else {
|
|
tooltip = "Gitea repository not available";
|
|
}
|
|
|
|
return giteaUrl ? (
|
|
<Button variant="ghost" size="icon" asChild>
|
|
<a
|
|
href={giteaUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={tooltip}
|
|
>
|
|
<SiGitea className="h-4 w-4" />
|
|
</a>
|
|
</Button>
|
|
) : (
|
|
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
|
<SiGitea className="h-4 w-4" />
|
|
</Button>
|
|
);
|
|
})()}
|
|
<Button variant="ghost" size="icon" asChild>
|
|
<a
|
|
href={repo.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title="View on GitHub"
|
|
>
|
|
<SiGithub className="h-4 w-4" />
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Bar */}
|
|
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
|
<span className="text-sm font-medium text-foreground">
|
|
{hasAnyFilter
|
|
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
|
|
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
|
|
</span>
|
|
</div>
|
|
{hasAnyFilter && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Filters applied
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RepoActionButton({
|
|
repo,
|
|
isLoading,
|
|
onMirror,
|
|
onSync,
|
|
onRetry,
|
|
}: {
|
|
repo: { id: string; status: string };
|
|
isLoading: boolean;
|
|
onMirror: () => void;
|
|
onSync: () => void;
|
|
onRetry: () => void;
|
|
}) {
|
|
let label = "";
|
|
let icon = <></>;
|
|
let onClick = () => {};
|
|
let disabled = isLoading;
|
|
|
|
if (repo.status === "failed") {
|
|
label = "Retry";
|
|
icon = <RotateCcw className="h-4 w-4 mr-1" />;
|
|
onClick = onRetry;
|
|
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
|
|
label = "Sync";
|
|
icon = <RefreshCw className="h-4 w-4 mr-1" />;
|
|
onClick = onSync;
|
|
disabled ||= repo.status === "syncing";
|
|
} else if (["imported", "mirroring"].includes(repo.status)) {
|
|
label = "Mirror";
|
|
icon = <GitFork className="h-4 w-4 mr-1" />;
|
|
onClick = onMirror;
|
|
disabled ||= repo.status === "mirroring";
|
|
} else {
|
|
return null; // unsupported status
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
className="min-w-[80px] justify-start"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
|
{label}
|
|
</>
|
|
) : (
|
|
<>
|
|
{icon}
|
|
{label}
|
|
</>
|
|
)}
|
|
</Button>
|
|
);
|
|
}
|