mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-01-29 05:40:55 +03:00
🎉 Gitea Mirror: Added
This commit is contained in:
150
src/components/organizations/AddOrganizationDialog.tsx
Normal file
150
src/components/organizations/AddOrganizationDialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import { LoaderCircle, Plus } from "lucide-react";
|
||||
import type { MembershipRole } from "@/types/organizations";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
interface AddOrganizationDialogProps {
|
||||
isDialogOpen: boolean;
|
||||
setIsDialogOpen: (isOpen: boolean) => void;
|
||||
onAddOrganization: ({
|
||||
org,
|
||||
role,
|
||||
}: {
|
||||
org: string;
|
||||
role: MembershipRole;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AddOrganizationDialog({
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
onAddOrganization,
|
||||
}: AddOrganizationDialogProps) {
|
||||
const [org, setOrg] = useState<string>("");
|
||||
const [role, setRole] = useState<MembershipRole>("member");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!org || org.trim() === "") {
|
||||
setError("Please enter a valid organization name.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await onAddOrganization({ org, role });
|
||||
|
||||
setError("");
|
||||
setOrg("");
|
||||
setRole("member");
|
||||
setIsDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to add repository.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
|
||||
<Plus className="h-6 w-6" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Organization</DialogTitle>
|
||||
<DialogDescription>
|
||||
You can add public organizations
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Organization Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={org}
|
||||
onChange={(e) => setOrg(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="e.g., microsoft"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Membership Role
|
||||
</label>
|
||||
|
||||
<RadioGroup
|
||||
value={role}
|
||||
onValueChange={(val) => setRole(val as MembershipRole)}
|
||||
className="flex flex-col gap-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="member" id="r1" />
|
||||
<Label htmlFor="r1">Member</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="admin" id="r2" />
|
||||
<Label htmlFor="r2">Admin</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="billing_manager" id="r3" />
|
||||
<Label htmlFor="r3">Billing Manager</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Add Repository"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
377
src/components/organizations/Organization.tsx
Normal file
377
src/components/organizations/Organization.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal, Plus } from "lucide-react";
|
||||
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
||||
import { OrganizationList } from "./OrganizationsList";
|
||||
import AddOrganizationDialog from "./AddOrganizationDialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiRequest } from "@/lib/utils";
|
||||
import {
|
||||
membershipRoleEnum,
|
||||
type AddOrganizationApiRequest,
|
||||
type AddOrganizationApiResponse,
|
||||
type MembershipRole,
|
||||
type OrganizationsApiResponse,
|
||||
} from "@/types/organizations";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Organization() {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
const { user } = useAuth();
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
searchTerm: "",
|
||||
membershipRole: "",
|
||||
status: "",
|
||||
});
|
||||
const [loadingOrgIds, setLoadingOrgIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
|
||||
|
||||
// Create a stable callback using useCallback
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
if (data.organizationId) {
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) =>
|
||||
org.id === data.organizationId
|
||||
? { ...org, status: data.status, details: data.details }
|
||||
: org
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Received new log:", data);
|
||||
}, []);
|
||||
|
||||
// Use the SSE hook
|
||||
const { connected } = useSSE({
|
||||
userId: user?.id,
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
const fetchOrganizations = useCallback(async () => {
|
||||
if (!user || !user.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await apiRequest<OrganizationsApiResponse>(
|
||||
`/github/organizations?userId=${user.id}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
setOrganizations(response.organizations);
|
||||
return true;
|
||||
} else {
|
||||
toast.error(response.error || "Error fetching organizations");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error fetching organizations"
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrganizations();
|
||||
}, [fetchOrganizations]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const success = await fetchOrganizations();
|
||||
if (success) {
|
||||
toast.success("Organizations refreshed successfully.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMirrorOrg = async ({ orgId }: { orgId: string }) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingOrgIds((prev) => new Set(prev).add(orgId));
|
||||
|
||||
const reqPayload: MirrorOrgRequest = {
|
||||
userId: user.id,
|
||||
organizationIds: [orgId],
|
||||
};
|
||||
|
||||
const response = await apiRequest<MirrorOrgResponse>("/job/mirror-org", {
|
||||
method: "POST",
|
||||
data: reqPayload,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Mirroring started for organization ID: ${orgId}`);
|
||||
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) => {
|
||||
const updated = response.organizations.find((o) => o.id === org.id);
|
||||
return updated ? updated : org;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(response.error || "Error starting mirror job");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting mirror job"
|
||||
);
|
||||
} finally {
|
||||
setLoadingOrgIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(orgId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOrganization = async ({
|
||||
org,
|
||||
role,
|
||||
}: {
|
||||
org: string;
|
||||
role: MembershipRole;
|
||||
}) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reqPayload: AddOrganizationApiRequest = {
|
||||
userId: user.id,
|
||||
org,
|
||||
role,
|
||||
};
|
||||
|
||||
const response = await apiRequest<AddOrganizationApiResponse>(
|
||||
"/sync/organization",
|
||||
{
|
||||
method: "POST",
|
||||
data: reqPayload,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Organization added successfully`);
|
||||
setOrganizations((prev) => [...prev, response.organization]);
|
||||
|
||||
await fetchOrganizations();
|
||||
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
searchTerm: org,
|
||||
}));
|
||||
} else {
|
||||
toast.error(response.error || "Error adding organization");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error adding organization"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMirrorAllOrgs = async () => {
|
||||
try {
|
||||
if (!user || !user.id || organizations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out organizations that are already mirrored to avoid duplicate operations
|
||||
const eligibleOrgs = organizations.filter(
|
||||
(org) =>
|
||||
org.status !== "mirroring" && org.status !== "mirrored" && org.id
|
||||
);
|
||||
|
||||
if (eligibleOrgs.length === 0) {
|
||||
toast.info("No eligible organizations to mirror");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all organization IDs
|
||||
const orgIds = eligibleOrgs.map((org) => org.id as string);
|
||||
|
||||
// Set loading state for all organizations being mirrored
|
||||
setLoadingOrgIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
orgIds.forEach((id) => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
const reqPayload: MirrorOrgRequest = {
|
||||
userId: user.id,
|
||||
organizationIds: orgIds,
|
||||
};
|
||||
|
||||
const response = await apiRequest<MirrorOrgResponse>("/job/mirror-org", {
|
||||
method: "POST",
|
||||
data: reqPayload,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Mirroring started for ${orgIds.length} organizations`);
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) => {
|
||||
const updated = response.organizations.find((o) => o.id === org.id);
|
||||
return updated ? updated : org;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(response.error || "Error starting mirror jobs");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting mirror jobs"
|
||||
);
|
||||
} finally {
|
||||
// Reset loading states - we'll let the SSE updates handle status changes
|
||||
setLoadingOrgIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique organization names for combobox (since Organization has no owner field)
|
||||
const ownerOptions = Array.from(
|
||||
new Set(
|
||||
organizations.map((org) => org.name).filter((v): v is string => !!v)
|
||||
)
|
||||
).sort();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
{/* Combine search and actions into a single flex row */}
|
||||
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Organizations..."
|
||||
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={filter.searchTerm}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Membership Role Filter */}
|
||||
<Select
|
||||
value={filter.membershipRole || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
membershipRole: value === "all" ? "" : (value as MembershipRole),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
||||
<SelectValue placeholder="All Roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["all", ...membershipRoleEnum.options].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role === "all"
|
||||
? "All Roles"
|
||||
: role
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<Select
|
||||
value={filter.status || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
status:
|
||||
value === "all"
|
||||
? ""
|
||||
: (value as
|
||||
| ""
|
||||
| "imported"
|
||||
| "mirroring"
|
||||
| "mirrored"
|
||||
| "failed"
|
||||
| "syncing"
|
||||
| "synced"),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
"all",
|
||||
"imported",
|
||||
"mirroring",
|
||||
"mirrored",
|
||||
"failed",
|
||||
"syncing",
|
||||
"synced",
|
||||
].map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status === "all"
|
||||
? "All Statuses"
|
||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="default" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllOrgs}
|
||||
disabled={isLoading || loadingOrgIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<OrganizationList
|
||||
organizations={organizations}
|
||||
isLoading={isLoading || !connected}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
loadingOrgIds={loadingOrgIds}
|
||||
onMirror={handleMirrorOrg}
|
||||
onAddOrganization={() => setIsDialogOpen(true)}
|
||||
/>
|
||||
|
||||
<AddOrganizationDialog
|
||||
onAddOrganization={handleAddOrganization}
|
||||
isDialogOpen={isDialogOpen}
|
||||
setIsDialogOpen={setIsDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/components/organizations/OrganizationsList.tsx
Normal file
178
src/components/organizations/OrganizationsList.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useMemo } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, RefreshCw, Building2 } 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { getStatusColor } 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;
|
||||
}
|
||||
|
||||
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 ?? "");
|
||||
|
||||
return (
|
||||
<Card key={index} className="overflow-hidden p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full capitalize ${
|
||||
org.membershipRole === "member"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-purple-100 text-purple-800"
|
||||
}`}
|
||||
>
|
||||
{org.membershipRole}
|
||||
{/* needs to be updated */}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{org.repositoryCount}{" "}
|
||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`include-${org.id}`}
|
||||
name={`include-${org.id}`}
|
||||
checked={org.status === "mirrored"}
|
||||
disabled={
|
||||
loadingOrgIds.has(org.id ?? "") ||
|
||||
org.status === "mirrored" ||
|
||||
org.status === "mirroring"
|
||||
}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (checked && !org.isIncluded && org.id) {
|
||||
onMirror({ orgId: org.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`include-${org.id}`}
|
||||
className="ml-2 text-sm select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Include in mirroring
|
||||
</label>
|
||||
|
||||
{isLoading && (
|
||||
<RefreshCw className="opacity-50 h-4 w-4 animate-spin ml-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={`https://github.com/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* dont know if this looks good. maybe revised */}
|
||||
<div className="flex items-center gap-2 justify-end mt-4">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(org.status)}`}
|
||||
/>
|
||||
<span className="text-sm capitalize">{org.status}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user