diff --git a/src/custom/CustomCatalog/style.tsx b/src/custom/CustomCatalog/style.tsx index 38fb8005..4fa95643 100644 --- a/src/custom/CustomCatalog/style.tsx +++ b/src/custom/CustomCatalog/style.tsx @@ -348,15 +348,16 @@ export const CardBack = styled('div')(({ isCatalog }) => ({ }) })); -const getBackground = (isLightMode: boolean) => { +export const getCatalogCardBackground = (isLightMode: boolean) => { const lightGradient = `linear-gradient(to left bottom, ${WHITESMOKE}, ${GRAY97},white, white, white, white, white, white, white, white, ${WHITESMOKE}, ${GRAY97})`; const darkGradient = `linear-gradient(to right top, ${DARK_PRIMARY_COLOR}, ${accentGrey[30]}, ${accentGrey[20]}, ${accentGrey[10]}, ${accentGrey[10]}, ${accentGrey[10]}, ${accentGrey[10]}, ${accentGrey[10]}, ${accentGrey[10]}, ${charcoal[20]}, ${charcoal[10]}, black)`; return isLightMode ? lightGradient : darkGradient; }; + export const CardFront = styled('div')(({ shouldFlip, isDetailed, theme }) => { const isLightMode = theme.palette.mode === 'light'; - const background = getBackground(isLightMode); + const background = getCatalogCardBackground(isLightMode); const boxShadow = `2px 2px 3px 0px ${theme.palette.background.brand?.default}`; return { @@ -414,7 +415,7 @@ export const DesignAuthorName = styled('div')(() => ({ export const CatalogEmptyStateDiv = styled('div')(({ theme }) => { const isLightMode = theme.palette.mode === 'light'; - const background = getBackground(isLightMode); + const background = getCatalogCardBackground(isLightMode); const boxShadow = `2px 2px 3px 0px ${theme.palette.background.brand?.default}`; return { diff --git a/src/custom/PerformersSection/PerformersSection.tsx b/src/custom/PerformersSection/PerformersSection.tsx new file mode 100644 index 00000000..2fdcc1d8 --- /dev/null +++ b/src/custom/PerformersSection/PerformersSection.tsx @@ -0,0 +1,316 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { memo, useMemo } from 'react'; +import { + CloneIcon, + DeploymentsIcon, + DownloadIcon, + OpenIcon, + ShareIcon, + TropyIcon +} from '../../icons'; +import { useTheme } from '../../theme'; +import { Pattern } from '../CustomCatalog/CustomCard'; +import { ErrorBoundary } from '../ErrorBoundary'; +import { StateCardSekeleton } from './PerformersToogleButton'; +import { + CardsContainer, + ContentWrapper, + ErrorContainer, + HeaderSection, + HeaderTitle, + IconContainer, + MainContainer, + RepoSection, + RepoTitle, + StatsValue, + StatusLabel, + StyledCard, + Title, + UserNameText +} from './styles'; + +interface MetricConfig { + label: string; + icon: React.ComponentType; + id: string; + countKey: keyof Pattern; +} + +interface BaseQueryParams { + pathType: string; + page: number; + pagesize: number; + metrics: boolean; + expandUser: boolean; + trim: boolean; + order?: string; +} + +interface StatCardProps { + label: string; + count: number; + patternName: string; + pattern: Pattern; + userName: string; + userid: string; + icon: React.ComponentType; + status: string; + id: string; + onCardClick: (pattern: Pattern) => void; + onIconClick: () => void; + onAuthorClick: (userId: string) => void; + onStatusClick: (status: string) => void; +} + +interface PerformersSectionProps { + useGetCatalogFilters: (params: any) => any; + onCardClick: (pattern: Pattern) => void; + onIconClick: () => void; + onAuthorClick: (userId: string) => void; + onStatusClick: (status: string) => void; +} + +type MetricType = 'view' | 'clone' | 'download' | 'deployment' | 'share'; + +const BASE_QUERY_PARAMS: BaseQueryParams = { + pathType: 'pattern', + page: 0, + pagesize: 1, + metrics: true, + expandUser: true, + trim: true +}; + +const METRICS: Record = { + view: { + label: 'Most Opens', + icon: OpenIcon, + id: 'open-icon', + countKey: 'view_count' + }, + clone: { + label: 'Most Clones', + icon: CloneIcon, + id: 'clone-icon', + countKey: 'clone_count' + }, + download: { + label: 'Most Downloads', + icon: DownloadIcon, + id: 'download-icon', + countKey: 'download_count' + }, + deployment: { + label: 'Most Deploys', + icon: DeploymentsIcon, + id: 'deployments-icon', + countKey: 'deployment_count' + }, + share: { + label: 'Most Shares', + icon: ShareIcon, + id: 'share-icon', + countKey: 'share_count' + } +}; + +const createQueryParams = (metric: MetricType): BaseQueryParams => ({ + ...BASE_QUERY_PARAMS, + order: `${METRICS[metric].countKey} desc` +}); + +const StatCardComponent: React.FC = ({ + label, + count, + patternName, + pattern, + userName, + userid, + icon: Icon, + status, + id, + onCardClick, + onIconClick, + onAuthorClick, + onStatusClick +}) => { + const handleCardClick = () => { + onCardClick(pattern); + }; + + const handleIconClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onIconClick(); + }; + + const handleAuthorClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onAuthorClick(userid); + }; + + const handleStatusClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onStatusClick(status); + }; + + return ( + + + + {label} + + + + + + {count} + + + {patternName} + by {userName} + + + + {status} + + + ); +}; +interface PageArgs { + search?: string; + order?: string; + pagesize?: number; + page?: number; + [key: string]: any; +} + +const withDefaultPageArgs = (args: PageArgs = {}): PageArgs => ({ + search: args.search ?? '', + order: args.order ?? '', + pagesize: args.pagesize ?? 0, + page: args.page ?? 0, + ...args +}); + +const StatCard = memo(StatCardComponent); +StatCard.displayName = 'StatCard'; + +const useMetricQueries = (useGetCatalogFilters: PerformersSectionProps['useGetCatalogFilters']) => { + const viewQuery = useGetCatalogFilters(withDefaultPageArgs(createQueryParams('view'))); + + const cloneQuery = useGetCatalogFilters(withDefaultPageArgs(createQueryParams('clone'))); + + const downloadQuery = useGetCatalogFilters(withDefaultPageArgs(createQueryParams('download'))); + + const deploymentQuery = useGetCatalogFilters( + withDefaultPageArgs(createQueryParams('deployment')) + ); + + const shareQuery = useGetCatalogFilters(withDefaultPageArgs(createQueryParams('share'))); + + const metricQueries = { + view: viewQuery, + clone: cloneQuery, + download: downloadQuery, + deployment: deploymentQuery, + share: shareQuery + }; + + return { + queries: metricQueries, + isLoading: Object.values(metricQueries).some((query) => query.isLoading), + hasError: Object.values(metricQueries).some((query) => query.isError) + }; +}; + +const processQueryData = ( + queries: Record, + metric: MetricType +): Omit< + StatCardProps, + 'onCardClick' | 'onIconClick' | 'onAuthorClick' | 'onStatusClick' +> | null => { + const query = queries[metric]; + const config = METRICS[metric]; + const pattern = query?.isSuccess && query.data?.patterns?.[0]; + + if (!pattern) return null; + + return { + label: config.label, + count: pattern[config.countKey], + patternName: pattern.name || 'Unknown', + pattern: pattern, + userName: pattern.user?.first_name || 'Unknown', + userid: pattern.user?.id, + icon: config.icon, + id: config.id, + status: pattern?.catalog_data?.content_class + }; +}; + +const PerformersSection: React.FC = ({ + useGetCatalogFilters, + onCardClick, + onIconClick, + onAuthorClick, + onStatusClick +}) => { + const theme = useTheme(); + const { queries, isLoading, hasError } = useMetricQueries(useGetCatalogFilters); + + const stats = useMemo( + () => + (Object.keys(METRICS) as MetricType[]) + .map((metric) => processQueryData(queries, metric)) + .filter( + ( + stat + ): stat is Omit< + StatCardProps, + 'onCardClick' | 'onIconClick' | 'onAuthorClick' | 'onStatusClick' + > => Boolean(stat) + ), + [queries] + ); + + if (hasError) + return ( + + Error loading statistics. Please try again later. + + ); + + return ( + + + + Top Performers + <TropyIcon + style={{ + height: '2rem', + width: '2rem', + color: theme.palette.icon.secondary + }} + /> + + + {isLoading && } + {!isLoading && + stats.map((stat, index) => ( + + ))} + + + + ); +}; + +export default memo(PerformersSection); diff --git a/src/custom/PerformersSection/PerformersToogleButton.tsx b/src/custom/PerformersSection/PerformersToogleButton.tsx new file mode 100644 index 00000000..a0555408 --- /dev/null +++ b/src/custom/PerformersSection/PerformersToogleButton.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Button, Skeleton } from '../../base'; +import { TropyIcon } from '../../icons'; +import { useTheme } from '../../theme'; +import { CustomTooltip } from '../CustomTooltip'; +import { CardSkeleton } from './styles'; + +interface PerformersSectionButtonProps { + open: boolean; + handleClick: () => void; +} + +const PerformersSectionButton: React.FC = ({ open, handleClick }) => { + const theme = useTheme(); + + return ( + + + + + + ); +}; + +export const StateCardSekeleton = () => { + return ( + + {[...Array(5)].map((_, index) => ( + + ))} + + ); +}; +export default PerformersSectionButton; diff --git a/src/custom/PerformersSection/index.ts b/src/custom/PerformersSection/index.ts new file mode 100644 index 00000000..1e6aa1ea --- /dev/null +++ b/src/custom/PerformersSection/index.ts @@ -0,0 +1,3 @@ +import PerformersSection from './PerformersSection'; +import PerformersSectionButton from './PerformersToogleButton'; +export { PerformersSection, PerformersSectionButton }; diff --git a/src/custom/PerformersSection/styles.tsx b/src/custom/PerformersSection/styles.tsx new file mode 100644 index 00000000..365b7886 --- /dev/null +++ b/src/custom/PerformersSection/styles.tsx @@ -0,0 +1,261 @@ +import { Box, Card, CardContent, Typography } from '../../base'; +import { DARK_TEAL, styled } from '../../theme'; +import { getCatalogCardBackground } from '../CustomCatalog/style'; + +interface StatusLabelProps { + labelType?: 'community' | 'official' | string; +} + +interface ContentWrapperProps { + cardId?: string; +} + +interface StyledCardProps { + status?: string; +} + +export const ChartDiv = styled(Box)(() => ({ + padding: '1rem', + borderRadius: '1rem', + backgroundColor: 'white', + width: '100%', + alignSelf: 'center', + height: '20rem', + marginBottom: '1rem', + boxShadow: '0px 2px 10px rgba(0, 0, 0, 0.2)', + display: 'block', + ['@media (max-width:900px)']: { + height: '18rem', + marginInline: '0', + padding: '0.5rem' + } +})); + +export const LoadButtonDiv = styled('div')(() => ({ + width: '100%', + justifyContent: 'center', + display: 'flex', + alignItems: 'center', + marginTop: '20px' +})); + +export const EmptyContainer = styled(Box)(() => ({ + display: 'flex', + justifyContent: 'space-evenly', + gap: '2.5rem', + width: '100%' +})); + +export const ContentContainer = styled(Box)(() => ({ + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-evenly', + gap: '2.5rem', + width: '100%' +})); + +export const EmptyStateDiv = styled('div')(({ theme }) => ({ + backgroundColor: theme.palette.common.white, + textAlign: 'center', + borderRadius: '1rem', + width: '100%', + padding: '1.5rem', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center' +})); + +export const MainContainer = styled(Box)(({ theme }) => ({ + background: + theme.palette.mode === 'light' + ? theme.palette.background.default + : theme.palette.background.secondary, + paddingTop: theme.spacing(2), + borderRadius: '1rem', + marginBottom: theme.spacing(4), + display: 'flex', + alignItems: 'center', + flexDirection: 'column' +})); + +export const Title = styled(Typography)(({ theme }) => ({ + fontSize: '1.5rem', + fontWeight: 600, + color: theme.palette.text.default, + paddingLeft: theme.spacing(2), + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + placeSelf: 'flex-start' +})); + +export const StyledCard = styled(Card)(({ theme }) => ({ + width: 'inherit', + minWidth: '150px', + borderRadius: '16px', + position: 'relative', + overflow: 'hidden', + transition: 'all 0.3s ease-in-out', + background: getCatalogCardBackground(theme.palette.mode === 'light'), + border: '1px solid transparent', + boxShadow: '2px 2px 3px 2px rgb(0, 211, 169, 0.5)', + '&:hover': { + transform: 'translateY(-4px)', + boxShadow: `2px 2px 3px 2px ${theme.palette.text.brand}`, + cursor: 'pointer' + } +})); + +export const CardSkeleton = styled('div')(() => ({ + display: 'flex', + gap: '10px', + flexWrap: 'nowrap', + justifyContent: 'center', + width: '100%' +})); + +export const IconContainer = styled(Box)(() => ({ + width: '2rem', + height: '2rem', + padding: '0.25rem', + borderRadius: '8px', + backgroundColor: '#D6FFF7', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'all 0.3s ease', + '& svg': { + transition: 'transform 0.3s ease' + }, + '&:hover svg': { + transform: 'scale(1.2)' + }, + '& .clone-icon': { + width: '20px', + height: '20px' + } +})); + +export const ContentWrapper = styled(CardContent)(({ cardId, theme }) => ({ + height: '100%', + display: 'flex', + flexDirection: 'column', + padding: theme.spacing(2), + paddingInline: cardId === 'download-icon' ? '12px' : theme.spacing(2), + '&:last-child': { + paddingBottom: theme.spacing(2) + } +})); + +export const HeaderSection = styled(Box)({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: '1rem', + gap: '0.6rem' +}); + +export const HeaderTitle = styled(Typography)(({ theme }) => ({ + fontSize: '0.85rem', + fontWeight: 'bold', + color: theme.palette.text.default, + lineHeight: 1.2, + marginTop: '4px', + textTransform: 'uppercase', + letterSpacing: '0.5px' +})); + +export const StatsValue = styled(Typography)(({ theme }) => ({ + fontSize: '24px', + fontWeight: 700, + color: theme.palette.icon.secondary, + marginBottom: '0.5rem', + lineHeight: 1.2 +})); + +export const RepoSection = styled(Box)(({ theme }) => ({ + marginBlock: '.35rem', + padding: '8px', + borderRadius: '8px', + background: theme.palette.mode === 'light' ? '#f8fafc' : DARK_TEAL, + color: theme.palette.mode === 'light' ? 'rgba(26, 26, 26, .8)' : theme.palette.text.default, + transition: 'background 0.3s ease, filter 0.3s ease', + '&:hover': { + filter: 'brightness(0.98)' + } +})); + +export const RepoTitle = styled(Typography)(({ theme }) => ({ + fontSize: '0.9rem', + fontWeight: 1000, + color: theme.palette.text.default, + lineHeight: 1.3, + marginBottom: '4px', + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + textOverflow: 'ellipsis', + height: '2.6em' +})); + +export const UserNameText = styled(Typography)(({ theme }) => ({ + fontSize: '0.75rem', + color: + theme.palette.mode === 'light' + ? theme.palette.text.constant?.disabled + : theme.palette.text.disabled, + marginBottom: '8px', + transition: 'color 0.3s ease', + + '&:hover': { + color: theme.palette.text.brand + } +})); + +export const CardsContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: '18px', + width: '100%', + overflowX: 'auto', + padding: '18px', + background: + theme.palette.mode === 'light' + ? theme.palette.background.default + : theme.palette.background.secondary, + borderRadius: '12px', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)', + paddingBottom: theme.spacing(3) +})); + +export const StatusLabel = styled(Box)(({ labelType, theme }) => ({ + position: 'absolute', + bottom: 0, + left: 0, + background: + labelType === 'community' + ? 'rgba(122,132,142,.8)' + : labelType === 'official' + ? theme.palette.background.cta?.default + : theme.palette.text.brand, + color: labelType === 'official' ? 'black' : 'white', + paddingInline: '1rem', + borderTopRightRadius: '1rem', + fontSize: '0.75rem', + fontWeight: 'bold', + letterSpacing: '0.5px', + textTransform: 'lowercase', + zIndex: 2, + transition: 'all 0.3s ease', + '&:hover': { + filter: 'brightness(1.2)' + } +})); + +export const ErrorContainer = styled(Box)(({ theme }) => ({ + padding: '1rem', + color: theme.palette.text.error, + fontSize: '1rem', + fontWeight: 500 +})); diff --git a/src/custom/ResponsiveDataTable.tsx b/src/custom/ResponsiveDataTable.tsx index 55098698..de09a30b 100644 --- a/src/custom/ResponsiveDataTable.tsx +++ b/src/custom/ResponsiveDataTable.tsx @@ -307,7 +307,7 @@ const ResponsiveDataTable = ({ year: 'numeric' }; - return new Intl.DateTimeFormat('un-US', dateOptions).format(date); + return new Intl.DateTimeFormat('en-US', dateOptions).format(date); }; const updatedOptions = { @@ -386,7 +386,7 @@ const ResponsiveDataTable = ({ }); updateCols && updateCols([...columns]); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [columnVisibility, updateCols, data]); + }, [columnVisibility, updateCols]); React.useEffect(() => { updateColumnsEffect(); diff --git a/src/custom/index.tsx b/src/custom/index.tsx index 1918d175..ded1e4f5 100644 --- a/src/custom/index.tsx +++ b/src/custom/index.tsx @@ -51,6 +51,7 @@ export { InputSearchField } from './InputSearchField'; export { LearningContent } from './LearningContent'; export { NavigationNavbar } from './NavigationNavbar'; export { Note } from './Note'; +export { PerformersSection, PerformersSectionButton } from './PerformersSection'; export { SetupPreReq } from './SetupPrerequisite'; export { StyledChapter } from './StyledChapter'; export { StyledSearchBar } from './StyledSearchBar'; diff --git a/src/icons/Tropy/TropyIcon.tsx b/src/icons/Tropy/TropyIcon.tsx new file mode 100644 index 00000000..61c27069 --- /dev/null +++ b/src/icons/Tropy/TropyIcon.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +interface TrophyIconProps { + width?: string; + height?: string; + fill?: string; + style?: React.CSSProperties; +} + +const TrophyIcon: React.FC = ({ + width = '24', + height = '24', + fill = 'currentColor', + style = {} +}) => ( + + + +); + +export default TrophyIcon; diff --git a/src/icons/Tropy/index.ts b/src/icons/Tropy/index.ts new file mode 100644 index 00000000..7f82f5cc --- /dev/null +++ b/src/icons/Tropy/index.ts @@ -0,0 +1 @@ +export { default as TropyIcon } from './TropyIcon'; diff --git a/src/icons/index.ts b/src/icons/index.ts index 22a3a721..1f0b66b8 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -94,6 +94,7 @@ export * from './TerminalIcon'; export * from './Toolkit'; export * from './Touch'; export * from './Triangle'; +export * from './Tropy'; export * from './Undeploy'; export * from './Undo'; export * from './Validate';