Files
gitea-mirror/src/components/organizations/OrganizationsList.tsx

270 lines
9.8 KiB
TypeScript

import { useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
import { SiGithub } from "react-icons/si";
import type { Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
interface OrganizationListProps {
organizations: Organization[];
isLoading: boolean;
filter: FilterParams;
setFilter: (filter: FilterParams) => void;
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
loadingOrgIds: Set<string>;
onAddOrganization?: () => void;
}
// Helper function to get status badge variant and icon
const getStatusBadge = (status: string | null) => {
switch (status) {
case "imported":
return { variant: "secondary" as const, label: "Not Mirrored", icon: null };
case "mirroring":
return { variant: "outline" as const, label: "Mirroring", icon: Clock };
case "mirrored":
return { variant: "default" as const, label: "Mirrored", icon: Check };
case "failed":
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
default:
return { variant: "secondary" as const, label: "Unknown", icon: null };
}
};
export function OrganizationList({
organizations,
isLoading,
filter,
setFilter,
onMirror,
loadingOrgIds,
onAddOrganization,
}: OrganizationListProps) {
const hasAnyFilter = Object.values(filter).some(
(val) => val?.toString().trim() !== ""
);
const filteredOrganizations = useMemo(() => {
let result = organizations;
if (filter.membershipRole) {
result = result.filter((org) => org.membershipRole === filter.membershipRole);
}
if (filter.status) {
result = result.filter((org) => org.status === filter.status);
}
if (filter.searchTerm) {
const fuse = new Fuse(result, {
keys: ["name", "type"],
threshold: 0.3,
});
result = fuse.search(filter.searchTerm).map((res) => res.item);
}
return result;
}, [organizations, filter]);
return isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-[136px] w-full" />
))}
</div>
) : filteredOrganizations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No organizations found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{hasAnyFilter
? "Try adjusting your search or filter criteria."
: "Add GitHub organizations to mirror their repositories."}
</p>
{hasAnyFilter ? (
<Button
variant="outline"
onClick={() => {
setFilter({
searchTerm: "",
membershipRole: "",
});
}}
>
Clear Filters
</Button>
) : (
<Button onClick={onAddOrganization}>
<Plus className="h-4 w-4 mr-2" />
Add Organization
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredOrganizations.map((org, index) => {
const isLoading = loadingOrgIds.has(org.id ?? "");
const statusBadge = getStatusBadge(org.status);
const StatusIcon = statusBadge.icon;
return (
<Card
key={index}
className={cn(
"overflow-hidden p-4 transition-all hover:shadow-md min-h-[160px]",
isLoading && "opacity-75"
)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Building2 className="h-5 w-5 text-muted-foreground" />
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer"
>
{org.name}
</a>
<span
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
org.membershipRole === "member"
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
}`}
>
{org.membershipRole}
</span>
</div>
</div>
<Badge variant={statusBadge.variant} className="ml-2">
{StatusIcon && <StatusIcon className={cn(
"h-3 w-3",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
</div>
<div className="text-sm text-muted-foreground mb-4">
<div className="flex items-center justify-between">
<span className="font-medium">
{org.repositoryCount}{" "}
{org.repositoryCount === 1 ? "repository" : "repositories"}
</span>
</div>
{/* Always render this section to prevent layout shift */}
<div className="flex gap-4 mt-2 text-xs min-h-[20px]">
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
<>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-16" />
</>
) : (
<>
{org.publicRepositoryCount !== undefined ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
{org.publicRepositoryCount} public
</span>
) : null}
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-orange-500" />
{org.privateRepositoryCount} private
</span>
) : null}
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-blue-500" />
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
</span>
) : null}
{/* Show a placeholder if no counts are available to maintain height */}
{org.publicRepositoryCount === undefined &&
org.privateRepositoryCount === undefined &&
org.forkRepositoryCount === undefined && (
<span className="invisible">Loading counts...</span>
)}
</>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{org.status === "imported" && (
<Button
size="sm"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Starting...
</>
) : (
"Mirror"
)}
</Button>
)}
{org.status === "mirroring" && (
<Button size="sm" disabled variant="outline">
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Mirroring...
</Button>
)}
{org.status === "mirrored" && (
<Button size="sm" disabled variant="secondary">
<Check className="h-3 w-3 mr-2" />
Mirrored
</Button>
)}
{org.status === "failed" && (
<Button
size="sm"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-3 w-3 mr-2" />
Retry
</>
)}
</Button>
)}
</div>
<Button variant="ghost" size="icon" asChild>
<a
href={`https://github.com/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</Card>
);
})}
</div>
);
}