Updates to Repository and Org Pages for Responsive Layouts

This commit is contained in:
Arunavo Ray
2025-07-07 22:02:43 +05:30
parent 6270907e70
commit 472f67a6ae
5 changed files with 886 additions and 435 deletions

View File

@@ -3,11 +3,17 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import type { MirrorJob } from '@/lib/db/schema'; import type { MirrorJob } from '@/lib/db/schema';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { RefreshCw } from 'lucide-react'; import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import { formatDate, getStatusColor } from '@/lib/utils'; import { formatDate, getStatusColor } from '@/lib/utils';
import { Skeleton } from '../ui/skeleton'; import { Skeleton } from '../ui/skeleton';
import type { FilterParams } from '@/types/filter'; import type { FilterParams } from '@/types/filter';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
type MirrorJobWithKey = MirrorJob & { _rowKey: string }; type MirrorJobWithKey = MirrorJob & { _rowKey: string };
@@ -73,7 +79,7 @@ export default function ActivityList({
count: filteredActivities.length, count: filteredActivities.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: (idx) => estimateSize: (idx) =>
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 120, expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 100,
overscan: 5, overscan: 5,
measureElement: (el) => el.getBoundingClientRect().height + 8, measureElement: (el) => el.getBoundingClientRect().height + 8,
}); });
@@ -155,8 +161,8 @@ export default function ActivityList({
}} }}
className='border-b px-4 pt-4' className='border-b px-4 pt-4'
> >
<div className='flex items-start gap-4'> <div className='flex items-start gap-3 sm:gap-4'>
<div className='relative mt-2'> <div className='relative mt-2 flex-shrink-0'>
<div <div
className={`h-2 w-2 rounded-full ${getStatusColor( className={`h-2 w-2 rounded-full ${getStatusColor(
activity.status, activity.status,
@@ -164,25 +170,112 @@ export default function ActivityList({
/> />
</div> </div>
<div className='flex-1'> <div className='flex-1 min-w-0'>
<div className='mb-1 flex flex-col sm:flex-row sm:items-center sm:justify-between'> <div className='mb-1 flex items-start justify-between gap-2'>
<p className='font-medium'>{activity.message}</p> <div className='flex-1 min-w-0'>
<p className='text-sm text-muted-foreground'> {/* Mobile: Show simplified status-based message */}
<div className='block sm:hidden'>
<p className='font-medium flex items-center gap-1.5'>
{activity.status === 'synced' ? (
<>
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400' />
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
</>
) : activity.status === 'mirrored' ? (
<>
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400' />
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
</>
) : activity.status === 'failed' ? (
<>
<X className='h-4 w-4 text-rose-600 dark:text-rose-400' />
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
</>
) : activity.status === 'syncing' ? (
<>
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin' />
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
</>
) : activity.status === 'mirroring' ? (
<>
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin' />
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
</>
) : activity.status === 'imported' ? (
<>
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400' />
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
</>
) : (
<span>{activity.message}</span>
)}
</p>
</div>
{/* Desktop: Show status with icon and full message in tooltip */}
<div className='hidden sm:block'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<p className='font-medium flex items-center gap-1.5 cursor-help'>
{activity.status === 'synced' ? (
<>
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400 flex-shrink-0' />
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
</>
) : activity.status === 'mirrored' ? (
<>
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0' />
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
</>
) : activity.status === 'failed' ? (
<>
<X className='h-4 w-4 text-rose-600 dark:text-rose-400 flex-shrink-0' />
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
</>
) : activity.status === 'syncing' ? (
<>
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin flex-shrink-0' />
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
</>
) : activity.status === 'mirroring' ? (
<>
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin flex-shrink-0' />
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
</>
) : activity.status === 'imported' ? (
<>
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
</>
) : (
<span className='truncate'>{activity.message}</span>
)}
</p>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[400px]">
<p className="whitespace-pre-wrap break-words">{activity.message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<p className='text-sm text-muted-foreground whitespace-nowrap flex-shrink-0 ml-2'>
{formatDate(activity.timestamp)} {formatDate(activity.timestamp)}
</p> </p>
</div> </div>
<div className='flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3'>
{activity.repositoryName && ( {activity.repositoryName && (
<p className='mb-2 text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground truncate'>
Repository: {activity.repositoryName} <span className='font-medium'>Repo:</span> {activity.repositoryName}
</p> </p>
)} )}
{activity.organizationName && ( {activity.organizationName && (
<p className='mb-2 text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground truncate'>
Organization: {activity.organizationName} <span className='font-medium'>Org:</span> {activity.organizationName}
</p> </p>
)} )}
</div>
{activity.details && ( {activity.details && (
<div className='mt-2'> <div className='mt-2'>
@@ -199,7 +292,7 @@ export default function ActivityList({
}) })
} }
> >
{isExpanded ? 'Hide Details' : 'Show Details'} {isExpanded ? 'Hide Details' : activity.status === 'failed' ? 'Show Error Details' : 'Show Details'}
</Button> </Button>
{isExpanded && ( {isExpanded && (

View File

@@ -69,19 +69,19 @@ export function MirrorDestinationEditor({
}; };
return ( return (
<div className={cn("flex items-center gap-2", className)}> <div className={cn("flex items-center gap-2 w-full", className)}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0 flex-1">
<Building2 className="h-3 w-3" /> <Building2 className="h-3 w-3 flex-shrink-0" />
<span className="font-medium">{organizationName}</span> <span className="font-medium truncate">{organizationName}</span>
<ArrowRight className="h-3 w-3" /> <ArrowRight className="h-3 w-3 flex-shrink-0" />
<span className={cn( <span className={cn(
"font-medium", "font-medium truncate",
hasOverride && "text-orange-600 dark:text-orange-400" hasOverride && "text-orange-600 dark:text-orange-400"
)}> )}>
{effectiveDestination} {effectiveDestination}
</span> </span>
{hasOverride && ( {hasOverride && (
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400"> <Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400 flex-shrink-0">
custom custom
</Badge> </Badge>
)} )}
@@ -92,11 +92,11 @@ export function MirrorDestinationEditor({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-6 w-6 p-0 opacity-60 hover:opacity-100" className="h-10 w-10 sm:h-6 sm:w-6 p-0 opacity-60 hover:opacity-100"
title="Edit mirror destination" title="Edit mirror destination"
disabled={isUpdating || isLoading} disabled={isUpdating || isLoading}
> >
<Edit3 className="h-3 w-3" /> <Edit3 className="h-5 w-5 sm:h-3 sm:w-3" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-80" align="end"> <PopoverContent className="w-80" align="end">

View File

@@ -127,9 +127,9 @@ export function OrganizationList({
}, [organizations, filter]); }, [organizations, filter]);
return isLoading ? ( return isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-[136px] w-full" /> <Skeleton key={i} className="h-[180px] w-full" />
))} ))}
</div> </div>
) : filteredOrganizations.length === 0 ? ( ) : filteredOrganizations.length === 0 ? (
@@ -161,7 +161,7 @@ export function OrganizationList({
)} )}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 pb-20 sm:pb-0">
{filteredOrganizations.map((org, index) => { {filteredOrganizations.map((org, index) => {
const isLoading = loadingOrgIds.has(org.id ?? ""); const isLoading = loadingOrgIds.has(org.id ?? "");
const statusBadge = getStatusBadge(org.status); const statusBadge = getStatusBadge(org.status);
@@ -171,20 +171,33 @@ export function OrganizationList({
<Card <Card
key={index} key={index}
className={cn( className={cn(
"overflow-hidden p-4 transition-all hover:shadow-md min-h-[160px]", "overflow-hidden p-4 sm:p-6 transition-all hover:shadow-lg hover:border-foreground/10",
isLoading && "opacity-75" isLoading && "opacity-75"
)} )}
> >
<div className="flex items-start justify-between mb-3"> {/* Mobile Layout */}
<div className="flex-1"> <div className="flex flex-col gap-3 sm:hidden">
<div className="flex items-center gap-2 mb-1"> {/* Header with org name and badges */}
<Building2 className="h-5 w-5 text-muted-foreground" /> <div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<a <a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`} href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer" className="font-medium hover:underline cursor-pointer truncate"
> >
{org.name} {org.name}
</a> </a>
</div>
<Badge variant={statusBadge.variant} className="flex-shrink-0">
{StatusIcon && <StatusIcon className={cn(
"h-3 w-3",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
</div>
<div className="flex items-center gap-2">
<span <span
className={`text-xs px-2 py-0.5 rounded-full capitalize ${ className={`text-xs px-2 py-0.5 rounded-full capitalize ${
org.membershipRole === "member" org.membershipRole === "member"
@@ -195,9 +208,10 @@ export function OrganizationList({
{org.membershipRole} {org.membershipRole}
</span> </span>
</div> </div>
</div>
{/* Destination override section */} {/* Destination override section */}
<div className="mt-2"> <div>
<MirrorDestinationEditor <MirrorDestinationEditor
organizationId={org.id!} organizationId={org.id!}
organizationName={org.name!} organizationName={org.name!}
@@ -207,116 +221,153 @@ export function OrganizationList({
/> />
</div> </div>
</div> </div>
<Badge variant={statusBadge.variant} className="ml-2">
{/* Desktop Layout */}
<div className="hidden sm:block">
{/* Header with org icon, name, role badge and status */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="text-xl font-semibold hover:underline cursor-pointer"
>
{org.name}
</a>
<Badge
variant={org.membershipRole === "member" ? "secondary" : "default"}
className="capitalize"
>
{org.membershipRole}
</Badge>
</div>
</div>
</div>
{/* Status badge */}
<Badge variant={statusBadge.variant} className="flex items-center gap-1">
{StatusIcon && <StatusIcon className={cn( {StatusIcon && <StatusIcon className={cn(
"h-3 w-3", "h-3.5 w-3.5",
org.status === "mirroring" && "animate-pulse" org.status === "mirroring" && "animate-pulse"
)} />} )} />}
{statusBadge.label} {statusBadge.label}
</Badge> </Badge>
</div> </div>
<div className="text-sm text-muted-foreground mb-4"> {/* Destination override section */}
<div className="flex items-center justify-between"> <div className="mb-4">
<span className="font-medium"> <MirrorDestinationEditor
{org.repositoryCount}{" "} organizationId={org.id!}
organizationName={org.name!}
currentDestination={org.destinationOrg}
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
isUpdating={isLoading}
/>
</div>
{/* Repository statistics */}
<div className="mb-4">
<div className="flex items-center gap-4 text-sm">
<div>
<span className="font-semibold text-lg">{org.repositoryCount}</span>
<span className="text-muted-foreground ml-1">
{org.repositoryCount === 1 ? "repository" : "repositories"} {org.repositoryCount === 1 ? "repository" : "repositories"}
</span> </span>
</div> </div>
{/* Always render this section to prevent layout shift */}
<div className="flex gap-4 mt-2 text-xs min-h-[20px]"> {/* Repository breakdown */}
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? ( {isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
<> <div className="flex items-center gap-3">
<Skeleton className="h-3 w-16" /> <Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-16" /> <Skeleton className="h-4 w-20" />
</> </div>
) : ( ) : (
<> <div className="flex items-center gap-3">
{org.publicRepositoryCount !== undefined ? ( {org.publicRepositoryCount !== undefined && (
<span className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full bg-green-500" /> <div className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span className="text-muted-foreground">
{org.publicRepositoryCount} public {org.publicRepositoryCount} public
</span> </span>
) : null} </div>
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? ( )}
<span className="flex items-center gap-1"> {org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 && (
<div className="h-2 w-2 rounded-full bg-orange-500" /> <div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-orange-500" />
<span className="text-muted-foreground">
{org.privateRepositoryCount} private {org.privateRepositoryCount} private
</span> </span>
) : null} </div>
{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>
</div> </div>
</div>
<div className="flex items-center justify-between"> {/* Mobile Actions */}
<div className="flex flex-col gap-3 sm:hidden">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{org.status === "imported" && ( {org.status === "imported" && (
<Button <Button
size="sm" size="default"
onClick={() => org.id && onMirror({ orgId: org.id })} onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading} disabled={isLoading}
className="w-full h-10"
> >
{isLoading ? ( {isLoading ? (
<> <>
<RefreshCw className="h-3 w-3 animate-spin mr-2" /> <RefreshCw className="h-4 w-4 animate-spin mr-2" />
Starting... Starting...
</> </>
) : ( ) : (
"Mirror" <>
<RefreshCw className="h-4 w-4 mr-2" />
Mirror Organization
</>
)} )}
</Button> </Button>
)} )}
{org.status === "mirroring" && ( {org.status === "mirroring" && (
<Button size="sm" disabled variant="outline"> <Button size="default" disabled variant="outline" className="w-full h-10">
<RefreshCw className="h-3 w-3 animate-spin mr-2" /> <RefreshCw className="h-4 w-4 animate-spin mr-2" />
Mirroring... Mirroring...
</Button> </Button>
)} )}
{org.status === "mirrored" && ( {org.status === "mirrored" && (
<Button size="sm" disabled variant="secondary"> <Button size="default" disabled variant="secondary" className="w-full h-10">
<Check className="h-3 w-3 mr-2" /> <Check className="h-4 w-4 mr-2" />
Mirrored Mirrored
</Button> </Button>
)} )}
{org.status === "failed" && ( {org.status === "failed" && (
<Button <Button
size="sm" size="default"
variant="destructive" variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })} onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading} disabled={isLoading}
className="w-full h-10"
> >
{isLoading ? ( {isLoading ? (
<> <>
<RefreshCw className="h-3 w-3 animate-spin mr-2" /> <RefreshCw className="h-4 w-4 animate-spin mr-2" />
Retrying... Retrying...
</> </>
) : ( ) : (
<> <>
<AlertCircle className="h-3 w-3 mr-2" /> <AlertCircle className="h-4 w-4 mr-2" />
Retry Retry Mirror
</> </>
)} )}
</Button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-2 justify-center">
{(() => { {(() => {
const giteaUrl = getGiteaOrgUrl(org); const giteaUrl = getGiteaOrgUrl(org);
@@ -337,33 +388,165 @@ export function OrganizationList({
} }
return giteaUrl ? ( return giteaUrl ? (
<Button variant="ghost" size="icon" asChild> <Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
<a <a
href={giteaUrl} href={giteaUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={tooltip} title={tooltip}
className="flex items-center justify-center gap-2"
> >
<SiGitea className="h-4 w-4" /> <SiGitea className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">Gitea</span>
</a> </a>
</Button> </Button>
) : ( ) : (
<Button variant="ghost" size="icon" disabled title={tooltip}> <Button variant="outline" size="default" disabled title={tooltip} className="flex-1 h-10">
<SiGitea className="h-4 w-4" /> <SiGitea className="h-4 w-4" />
<span className="text-xs ml-2">Gitea</span>
</Button> </Button>
); );
})()} })()}
<Button variant="ghost" size="icon" asChild> <Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
<a
href={`https://github.com/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
className="flex items-center justify-center gap-2"
>
<SiGithub className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">GitHub</span>
</a>
</Button>
</div>
</div>
{/* Desktop Actions */}
<div className="hidden sm:flex items-center justify-between mt-4">
<div className="flex items-center gap-2">
{org.status === "imported" && (
<Button
size="default"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Starting mirror...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Mirror Organization
</>
)}
</Button>
)}
{org.status === "mirroring" && (
<Button size="default" disabled variant="outline">
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Mirroring in progress...
</Button>
)}
{org.status === "mirrored" && (
<Button size="default" disabled variant="secondary">
<Check className="h-4 w-4 mr-2" />
Successfully mirrored
</Button>
)}
{org.status === "failed" && (
<Button
size="default"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button>
)}
</div>
<div className="flex items-center gap-2">
{(() => {
const giteaUrl = getGiteaOrgUrl(org);
// Determine tooltip based on status and configuration
let tooltip: string;
if (!giteaConfig?.url) {
tooltip = "Gitea not configured";
} else if (org.status === 'imported') {
tooltip = "Organization not yet mirrored to Gitea";
} else if (org.status === 'failed') {
tooltip = "Organization mirroring failed";
} else if (org.status === 'mirroring') {
tooltip = "Organization is being mirrored to Gitea";
} else if (giteaUrl) {
tooltip = "View on Gitea";
} else {
tooltip = "Gitea organization not available";
}
return (
<div className="flex items-center border rounded-md">
<Button
variant="ghost"
size="sm"
asChild={!!giteaUrl}
disabled={!giteaUrl}
title={tooltip}
className="rounded-none rounded-l-md border-r"
>
{giteaUrl ? (
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
>
<SiGitea className="h-4 w-4 mr-2" />
Gitea
</a>
) : (
<>
<SiGitea className="h-4 w-4 mr-2" />
Gitea
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
asChild
className="rounded-none rounded-r-md"
>
<a <a
href={`https://github.com/${org.name}`} href={`https://github.com/${org.name}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="View on GitHub" title="View on GitHub"
> >
<SiGithub className="h-4 w-4" /> <SiGithub className="h-4 w-4 mr-2" />
GitHub
</a> </a>
</Button> </Button>
</div> </div>
);
})()}
</div>
</div> </div>
</Card> </Card>
); );

View File

@@ -752,7 +752,6 @@ export default function Repository() {
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
{selectedRepoIds.size === 0 && (
<Button <Button
variant="default" variant="default"
size="icon" size="icon"
@@ -763,7 +762,6 @@ export default function Repository() {
> >
<FlipHorizontal className="h-4 w-4" /> <FlipHorizontal className="h-4 w-4" />
</Button> </Button>
)}
</div> </div>
{/* Desktop: Original layout */} {/* Desktop: Original layout */}
@@ -844,23 +842,79 @@ export default function Repository() {
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div>
</div>
{/* Action buttons - shows when items are selected or Mirror All on desktop */} {/* Bulk actions on desktop - integrated into the same line */}
<div className={`flex items-center gap-2 flex-wrap ${selectedRepoIds.size === 0 ? 'hidden sm:flex' : ''}`}> <div className="flex items-center gap-2 border-l pl-4">
{selectedRepoIds.size === 0 ? ( {selectedRepoIds.size === 0 ? (
<Button <Button
variant="default" variant="default"
onClick={handleMirrorAllRepos} onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0} disabled={isInitialLoading || loadingRepoIds.size > 0}
className="w-auto" className="whitespace-nowrap"
> >
<FlipHorizontal className="h-4 w-4 mr-2" /> <FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All Mirror All
</Button> </Button>
) : ( ) : (
<> <>
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setSelectedRepoIds(new Set())}
>
<X className="h-3 w-3" />
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="default"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="default"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</>
)}
</div>
</div>
</div>
{/* Action buttons for mobile - only show when items are selected */}
{selectedRepoIds.size > 0 && (
<div className="flex items-center gap-2 flex-wrap sm:hidden">
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md"> <div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{selectedRepoIds.size} selected {selectedRepoIds.size} selected
@@ -884,7 +938,7 @@ export default function Repository() {
disabled={loadingRepoIds.size > 0} disabled={loadingRepoIds.size > 0}
> >
<FlipHorizontal className="h-4 w-4 mr-2" /> <FlipHorizontal className="h-4 w-4 mr-2" />
<span className="hidden sm:inline">Mirror </span>({selectedRepoIds.size}) <span>Mirror </span>({selectedRepoIds.size})
</Button> </Button>
)} )}
@@ -912,9 +966,8 @@ export default function Repository() {
</Button> </Button>
)} )}
</div> </div>
</>
)}
</div> </div>
)}
{!isGitHubConfigured ? ( {!isGitHubConfigured ? (
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-md"> <div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-md">
@@ -946,7 +999,9 @@ export default function Repository() {
loadingRepoIds={loadingRepoIds} loadingRepoIds={loadingRepoIds}
selectedRepoIds={selectedRepoIds} selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds} onSelectionChange={setSelectedRepoIds}
onRefresh={() => fetchRepositories(false)} onRefresh={async () => {
await fetchRepositories(false);
}}
/> />
)} )}

View File

@@ -177,109 +177,162 @@ export default function RepositoryTable({
return ( return (
<Card className="mb-3"> <Card className="mb-3">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col gap-3">
{/* Header with checkbox and repo name */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)} onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
className="mt-1" className="mt-1 h-5 w-5"
aria-label={`Select ${repo.name}`}
/> />
<div className="flex-1 space-y-3"> <div className="flex-1 min-w-0">
{/* Repository Info */} <h3 className="font-medium text-base truncate">{repo.name}</h3>
<div>
<h3 className="font-medium text-sm break-all">{repo.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap"> <div className="flex items-center gap-2 mt-1 flex-wrap">
{repo.isPrivate && <Badge variant="secondary" className="text-xs"><Lock className="h-3 w-3 mr-1" />Private</Badge>} {repo.isPrivate && <Badge variant="secondary" className="text-xs h-5"><Lock className="h-3 w-3 mr-1" />Private</Badge>}
{repo.isForked && <Badge variant="secondary" className="text-xs"><GitFork className="h-3 w-3 mr-1" />Fork</Badge>} {repo.isForked && <Badge variant="secondary" className="text-xs h-5"><GitFork className="h-3 w-3 mr-1" />Fork</Badge>}
{repo.isStarred && <Badge variant="secondary" className="text-xs"><Star className="h-3 w-3 mr-1" />Starred</Badge>} {repo.isStarred && <Badge variant="secondary" className="text-xs h-5"><Star className="h-3 w-3 mr-1" />Starred</Badge>}
</div>
</div> </div>
</div> </div>
{/* Repository details */}
<div className="space-y-2">
{/* Owner & Organization */} {/* Owner & Organization */}
<div className="text-xs text-muted-foreground"> <div className="text-sm text-muted-foreground space-y-1">
<div>Owner: {repo.owner}</div> <div className="flex items-center gap-2">
{repo.organization && <div>Org: {repo.organization}</div>} <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Owner:</span>
{repo.destinationOrg && <div>Destination: {repo.destinationOrg}</div>} <span className="truncate">{repo.owner}</span>
</div>
{repo.organization && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Org:</span>
<span className="truncate">{repo.organization}</span>
</div>
)}
{repo.destinationOrg && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Dest:</span>
<span className="truncate">{repo.destinationOrg}</span>
</div>
)}
</div> </div>
{/* Status & Last Mirrored */} {/* Status & Last Mirrored */}
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} /> <div className={`h-2.5 w-2.5 rounded-full ${getStatusColor(repo.status)}`} />
<span className="capitalize">{repo.status}</span> <span className="text-sm font-medium capitalize">{repo.status}</span>
</div> </div>
<span className="text-muted-foreground"> <span className="text-xs text-muted-foreground">
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never"} {repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"}
</span> </span>
</div> </div>
</div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex flex-col gap-2">
{/* Primary action button */}
{(repo.status === "imported" || repo.status === "failed") && ( {(repo.status === "imported" || repo.status === "failed") && (
<Button <Button
size="sm" size="default"
variant="default" variant="default"
onClick={() => repo.id && onMirror({ repoId: repo.id })} onClick={() => repo.id && onMirror({ repoId: repo.id })}
disabled={isLoading} disabled={isLoading}
className="w-full h-10"
> >
<FlipHorizontal className="h-3 w-3 mr-1" /> {isLoading ? (
Mirror <>
<FlipHorizontal className="h-4 w-4 mr-2 animate-spin" />
Mirroring...
</>
) : (
<>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror Repository
</>
)}
</Button> </Button>
)} )}
{(repo.status === "mirrored" || repo.status === "synced") && ( {(repo.status === "mirrored" || repo.status === "synced") && (
<Button <Button
size="sm" size="default"
variant="outline" variant="outline"
onClick={() => repo.id && onSync({ repoId: repo.id })} onClick={() => repo.id && onSync({ repoId: repo.id })}
disabled={isLoading} disabled={isLoading}
className="w-full h-10"
> >
<RefreshCw className="h-3 w-3 mr-1" /> {isLoading ? (
Sync <>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Syncing...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Sync Repository
</>
)}
</Button> </Button>
)} )}
{repo.status === "failed" && ( {repo.status === "failed" && (
<Button <Button
size="sm" size="default"
variant="outline" variant="destructive"
onClick={() => repo.id && onRetry({ repoId: repo.id })} onClick={() => repo.id && onRetry({ repoId: repo.id })}
disabled={isLoading} disabled={isLoading}
className="w-full h-10"
> >
<RotateCcw className="h-3 w-3 mr-1" /> {isLoading ? (
Retry <>
<RotateCcw className="h-4 w-4 mr-2 animate-spin" />
Retrying...
</>
) : (
<>
<RotateCcw className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button> </Button>
)} )}
{/* Links */} {/* External links */}
<div className="flex gap-1 ml-auto"> <div className="flex gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8" asChild> <Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
<a <a
href={repo.url} href={repo.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="View on GitHub" title="View on GitHub"
className="flex items-center justify-center gap-2"
> >
<SiGithub className="h-4 w-4" /> <SiGithub className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">GitHub</span>
</a> </a>
</Button> </Button>
{giteaUrl ? ( {giteaUrl ? (
<Button variant="ghost" size="icon" className="h-8 w-8" asChild> <Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
<a <a
href={giteaUrl} href={giteaUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="View on Gitea" title="View on Gitea"
className="flex items-center justify-center gap-2"
> >
<SiGitea className="h-4 w-4" /> <SiGitea className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">Gitea</span>
</a> </a>
</Button> </Button>
) : ( ) : (
<Button variant="ghost" size="icon" className="h-8 w-8" disabled title="Not mirrored to Gitea"> <Button variant="outline" size="default" disabled className="flex-1 h-10 min-w-0">
<SiGitea className="h-4 w-4" /> <SiGitea className="h-4 w-4" />
<span className="text-xs ml-2">Gitea</span>
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -404,13 +457,14 @@ export default function RepositoryTable({
) : ( ) : (
<> <>
{/* Mobile card view */} {/* Mobile card view */}
<div className="lg:hidden"> <div className="lg:hidden pb-20">
{/* Select all checkbox */} {/* Select all checkbox */}
<div className="flex items-center gap-2 mb-3 p-2 bg-muted/50 rounded-md"> <div className="flex items-center gap-3 mb-3 p-3 bg-muted/50 rounded-md">
<Checkbox <Checkbox
checked={isAllSelected} checked={isAllSelected}
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
aria-label="Select all repositories" aria-label="Select all repositories"
className="h-5 w-5"
/> />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Select All ({filteredRepositories.length}) Select All ({filteredRepositories.length})
@@ -424,7 +478,7 @@ export default function RepositoryTable({
</div> </div>
{/* Desktop table view */} {/* Desktop table view */}
<div className="hidden lg:block border rounded-md"> <div className="hidden lg:flex flex-col border rounded-md">
{/* Table header */} {/* Table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50"> <div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]"> <div className="h-full p-3 flex items-center justify-center flex-[0.3]">
@@ -453,215 +507,281 @@ export default function RepositoryTable({
</div> </div>
</div> </div>
{/* Table body with virtualization */} {/* Table body wrapper (for a parent in virtualization) */}
<div <div
ref={tableParentRef} ref={tableParentRef}
className="overflow-auto max-h-[calc(100dvh-25rem)]" className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto"
style={{
contain: "strict",
}}
> >
<div <div
style={{ style={{
height: `${rowVirtualizer.getTotalSize()}px`, height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative", position: "relative",
}} }}
> >
{rowVirtualizer.getVirtualItems().map((virtualRow) => { {rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
const repo = filteredRepositories[virtualRow.index]; const repo = filteredRepositories[virtualRow.index];
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false; const isLoading = loadingRepoIds.has(repo.id ?? "");
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
const giteaUrl = getGiteaRepoUrl(repo);
return ( return (
<div <div
key={virtualRow.key} key={index}
ref={rowVirtualizer.measureElement}
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}} }}
className="flex items-center justify-between border-b bg-transparent hover:bg-muted/50 transition-colors" data-index={virtualRow.index}
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50"
> >
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]"> <div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox <Checkbox
checked={isSelected} checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)} onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
aria-label={`Select ${repo.name}`}
/> />
</div> </div>
<div className="h-full p-3 flex-[2.5] pr-2">
<div className="flex items-center gap-2"> {/* Repository */}
<span className="font-medium text-sm truncate">{repo.name}</span> <div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
{repo.isStarred && (
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
{repo.fullName}
</div>
</div>
{repo.isPrivate && ( {repo.isPrivate && (
<TooltipProvider> <span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
<Tooltip> Private
<TooltipTrigger> </span>
<Lock className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Private repository</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
{repo.isForked && ( {repo.isForked && (
<TooltipProvider> <span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
<Tooltip> Fork
<TooltipTrigger> </span>
<GitFork className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Forked repository</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{repo.isStarred && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Star className="h-3 w-3 text-yellow-500" />
</TooltipTrigger>
<TooltipContent>
<p>Starred repository</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground truncate"> {/* Owner */}
{repo.fullName} <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]">
<InlineDestinationEditor
repository={repo}
giteaConfig={giteaConfig}
onUpdate={handleUpdateDestination}
isUpdating={loadingRepoIds.has(repo.id ?? "")}
/>
</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> </p>
</div> </div>
<div className="h-full p-3 flex-[1] text-sm">
{repo.owner} {/* Status */}
</div> <div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
<div className="h-full p-3 flex-[1] text-sm"> {repo.status === "failed" && repo.errorMessage ? (
<div className="flex flex-col">
<span>{repo.organization || "-"}</span>
{repo.destinationOrg && repo.id && (
<InlineDestinationEditor
repositoryId={repo.id}
currentDestination={repo.destinationOrg}
onUpdate={handleUpdateDestination}
/>
)}
</div>
</div>
<div className="h-full p-3 flex-[1] text-sm">
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never"}
</div>
<div className="h-full p-3 flex-[1] flex items-center">
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
repo.status
)}`}
/>
<span className="text-sm capitalize">{repo.status}</span>
</div>
</div>
<div className="h-full p-3 flex-[1] flex items-center gap-1">
{(repo.status === "imported" || repo.status === "failed") && (
<Button
size="sm"
variant="default"
onClick={() => repo.id && onMirror({ repoId: repo.id })}
disabled={isLoading}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror
</Button>
)}
{(repo.status === "mirrored" || repo.status === "synced") && (
<Button
size="sm"
variant="outline"
onClick={() => repo.id && onSync({ repoId: repo.id })}
disabled={isLoading}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync
</Button>
)}
{repo.status === "failed" && (
<Button
size="sm"
variant="outline"
onClick={() => repo.id && onRetry({ repoId: repo.id })}
disabled={isLoading}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</div>
<div className="h-full p-3 flex-[0.8] flex items-center justify-center gap-1">
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild> <div className="flex items-center gap-x-2 cursor-help">
<a <div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
href={repo.url} <span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
target="_blank" </div>
rel="noopener noreferrer"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent className="max-w-xs">
<p>View on GitHub</p> <p className="text-sm">{repo.errorMessage}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : (
<>
<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);
{giteaUrl ? ( // Determine tooltip based on status and configuration
<TooltipProvider> let tooltip: string;
<Tooltip> if (!giteaConfig?.url) {
<TooltipTrigger asChild> 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> <Button variant="ghost" size="icon" asChild>
<a <a
href={giteaUrl} href={giteaUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={tooltip}
> >
<SiGitea className="h-4 w-4" /> <SiGitea className="h-4 w-4" />
</a> </a>
</Button> </Button>
</TooltipTrigger>
<TooltipContent>
<p>View on Gitea</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : ( ) : (
<TooltipProvider> <Button variant="ghost" size="icon" disabled title={tooltip}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" disabled>
<SiGitea className="h-4 w-4" /> <SiGitea className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> );
<TooltipContent> })()}
<p>Not mirrored to Gitea</p> <Button variant="ghost" size="icon" asChild>
</TooltipContent> <a
</Tooltip> href={repo.url}
</TooltipProvider> target="_blank"
)} rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
{/* Status Bar */}
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
<div className="flex items-center gap-2">
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : '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>
{/* Center - Live active indicator */}
{isLiveActive && (
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite'
}}
/>
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
Live active
</span>
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite',
animationDelay: '1s'
}}
/>
</div>
)}
{hasAnyFilter && (
<span className="text-xs text-muted-foreground">
Filters applied
</span>
)}
</div>
</div> </div>
</> </>
)} )}
</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 = <FlipHorizontal 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>
);
}