Improve game templates and examples display on build page (#6309)

This commit is contained in:
AlexandreS
2024-02-01 15:25:49 +01:00
committed by GitHub
parent 44a0e22f97
commit cf160bcca1
12 changed files with 692 additions and 376 deletions

View File

@@ -1,5 +1,6 @@
// @flow
import * as React from 'react';
import { t } from '@lingui/macro';
import { type Filters } from '../Utils/GDevelopServices/Filters';
import {
type AssetShortHeader,
@@ -32,14 +33,13 @@ import {
assetStoreHomePageState,
} from './AssetStoreNavigator';
import { type ChosenCategory } from '../UI/Search/FiltersChooser';
import shuffle from 'lodash/shuffle';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import {
getAssetPackFromUserFriendlySlug,
getPrivateAssetPackListingDataFromUserFriendlySlug,
} from './AssetStoreUtils';
import useAlertDialog from '../UI/Alert/useAlertDialog';
import { t } from '@lingui/macro';
import { getStableRandomArray } from '../Utils/Random';
const defaultSearchText = '';
@@ -186,12 +186,6 @@ const getPrivateAssetPackListingDataSearchTerms = (
privateAssetPack: PrivateAssetPackListingData
) => privateAssetPack.name + '\n' + privateAssetPack.description;
const getAssetPackRandomOrdering = (length: number): Array<number> => {
const array = new Array(length).fill(0).map((_, index) => index);
return shuffle(array);
};
export const AssetStoreStateProvider = ({
onlyAppStorePrivateAssetPacks,
shopNavigationState,
@@ -504,8 +498,8 @@ export const AssetStoreStateProvider = ({
return;
}
setAssetPackRandomOrdering({
starterPacks: getAssetPackRandomOrdering(assetPackCount),
privateAssetPacks: getAssetPackRandomOrdering(privateAssetPackCount),
starterPacks: getStableRandomArray(assetPackCount),
privateAssetPacks: getStableRandomArray(privateAssetPackCount),
});
},
[assetPackCount, privateAssetPackCount]

View File

@@ -25,6 +25,7 @@ import {
PublicAssetPackTile,
PrivateGameTemplateTile,
} from './ShopTiles';
import { shuffleArrayWith } from '../Utils/Random';
const cellSpacing = 2;
@@ -203,25 +204,20 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
? shopCategories[openedShopCategory].title
: null;
const starterPacksTiles: Array<React.Node> = starterPacks
.filter(
const starterPacksTiles: Array<React.Node> = shuffleArrayWith(
starterPacks.filter(
assetPack =>
!openedShopCategory ||
assetPack.categories.includes(openedShopCategory)
)
.map((pack, index) => ({
pos: assetPackRandomOrdering.starterPacks[index],
pack,
}))
.sort((a, b) => a.pos - b.pos)
.map(sortObject => sortObject.pack)
.map((assetPack, index) => (
<PublicAssetPackTile
assetPack={assetPack}
onSelect={() => onPublicAssetPackSelection(assetPack)}
key={`${assetPack.tag}-${index}`}
/>
));
),
assetPackRandomOrdering.starterPacks
).map((assetPack, index) => (
<PublicAssetPackTile
assetPack={assetPack}
onSelect={() => onPublicAssetPackSelection(assetPack)}
key={`${assetPack.tag}-${index}`}
/>
));
const { allStandAloneTiles, allBundleTiles } = React.useMemo(
() => {
@@ -230,52 +226,46 @@ export const AssetsHome = React.forwardRef<Props, AssetsHomeInterface>(
const privateAssetPackBundleTiles: Array<React.Node> = [];
const privateOwnedAssetPackBundleTiles: Array<React.Node> = [];
privateAssetPackListingDatas
.filter(
shuffleArrayWith(
privateAssetPackListingDatas.filter(
assetPackListingData =>
!openedShopCategory ||
assetPackListingData.categories.includes(openedShopCategory)
)
.map((listingData, index) => ({
pos: assetPackRandomOrdering.privateAssetPacks[index],
listingData,
}))
.sort((a, b) => a.pos - b.pos)
.map(sortObject => sortObject.listingData)
.filter(Boolean)
.forEach(assetPackListingData => {
const isPackOwned =
!!receivedAssetPacks &&
!!receivedAssetPacks.find(
pack => pack.id === assetPackListingData.id
);
const tile = (
<PrivateAssetPackTile
assetPackListingData={assetPackListingData}
onSelect={() => {
onPrivateAssetPackSelection(assetPackListingData);
}}
owned={isPackOwned}
key={assetPackListingData.id}
/>
),
assetPackRandomOrdering.privateAssetPacks
).forEach(assetPackListingData => {
const isPackOwned =
!!receivedAssetPacks &&
!!receivedAssetPacks.find(
pack => pack.id === assetPackListingData.id
);
if (
assetPackListingData.includedListableProductIds &&
!!assetPackListingData.includedListableProductIds.length
) {
if (isPackOwned) {
privateOwnedAssetPackBundleTiles.push(tile);
} else {
privateAssetPackBundleTiles.push(tile);
}
const tile = (
<PrivateAssetPackTile
assetPackListingData={assetPackListingData}
onSelect={() => {
onPrivateAssetPackSelection(assetPackListingData);
}}
owned={isPackOwned}
key={assetPackListingData.id}
/>
);
if (
assetPackListingData.includedListableProductIds &&
!!assetPackListingData.includedListableProductIds.length
) {
if (isPackOwned) {
privateOwnedAssetPackBundleTiles.push(tile);
} else {
if (isPackOwned) {
privateOwnedAssetPackStandAloneTiles.push(tile);
} else {
privateAssetPackStandAloneTiles.push(tile);
}
privateAssetPackBundleTiles.push(tile);
}
});
} else {
if (isPackOwned) {
privateOwnedAssetPackStandAloneTiles.push(tile);
} else {
privateAssetPackStandAloneTiles.push(tile);
}
}
});
const allBundleTiles = [
...privateOwnedAssetPackBundleTiles, // Display owned bundles first.

View File

@@ -22,37 +22,6 @@ import PrivateGameTemplateListItem from '../PrivateGameTemplates/PrivateGameTemp
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import { PrivateGameTemplateStoreContext } from '../PrivateGameTemplates/PrivateGameTemplateStoreContext';
// When showing examples, always put the starters first.
export const prepareExampleShortHeaders = (
examples: Array<ExampleShortHeader>
): Array<ExampleShortHeader> =>
examples.sort((example1, example2) => {
const isExample1Starter = example1.tags.includes('Starter');
const isExample2Starter = example2.tags.includes('Starter');
// Don't change starters order.
if (isExample1Starter && isExample2Starter) {
return 0;
}
let difference = (isExample2Starter ? 1 : 0) - (isExample1Starter ? 1 : 0);
if (difference) {
return difference;
}
difference =
(example2.tags.includes('game') ? 1 : 0) -
(example1.tags.includes('game') ? 1 : 0);
if (difference) {
return difference;
}
difference =
(example2.previewImageUrls.length ? 1 : 0) -
(example1.previewImageUrls.length ? 1 : 0);
if (difference) {
return difference;
}
return 0;
});
const getItemUniqueId = (
item: ExampleShortHeader | PrivateGameTemplateListingData
) => item.id;

View File

@@ -10,6 +10,7 @@ import {
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
} from '../Utils/GDevelopServices/Shop';
import type { ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import GridListTile from '@material-ui/core/GridListTile';
import createStyles from '@material-ui/core/styles/createStyles';
import makeStyles from '@material-ui/core/styles/makeStyles';
@@ -462,3 +463,49 @@ export const PrivateGameTemplateTile = ({
</GridListTile>
);
};
export const ExampleTile = ({
exampleShortHeader,
onSelect,
style,
}: {|
exampleShortHeader: ExampleShortHeader,
onSelect: () => void,
/** Props needed so that GridList component can adjust tile size */
style?: any,
|}) => {
const classesForGridListItem = useStylesForGridListItem();
return (
<GridListTile
classes={classesForGridListItem}
tabIndex={0}
onKeyPress={(event: SyntheticKeyboardEvent<HTMLLIElement>): void => {
if (shouldValidate(event)) {
onSelect();
}
}}
style={style}
onClick={onSelect}
>
<Paper elevation={2} style={styles.paper} background="light">
<CorsAwareImage
key={exampleShortHeader.name}
style={styles.previewImage}
src={
exampleShortHeader.previewImageUrls
? exampleShortHeader.previewImageUrls[0]
: ''
}
alt={`Preview image of example ${exampleShortHeader.name}`}
/>
<Column>
<Line justifyContent="flex-start" noMargin>
<Text style={styles.packTitle} size="body2">
{exampleShortHeader.name}
</Text>
</Line>
</Column>
</Paper>
</GridListTile>
);
};

View File

@@ -0,0 +1,76 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { Line } from '../../../../UI/Grid';
import { createStyles, makeStyles, useTheme } from '@material-ui/core/styles';
import Paper from '@material-ui/core/Paper';
import ButtonBase from '@material-ui/core/ButtonBase';
import Text from '../../../../UI/Text';
import Add from '../../../../UI/CustomSvgIcons/Add';
import { ColumnStackLayout } from '../../../../UI/Layout';
const styles = {
button: {
border: 'solid',
borderWidth: 1,
borderRadius: 8,
height: '100%',
display: 'flex',
},
buttonBackground: { width: '100%', height: '100%', padding: 20 },
};
const useStyles = () =>
makeStyles(theme =>
createStyles({
root: {
'&:hover': {
filter:
theme.palette.type === 'dark'
? 'brightness(130%)'
: 'brightness(90%)',
},
transition: 'filter 100ms ease',
},
})
)();
type Props = {|
onClick: () => void,
fullWidth?: boolean,
|};
const CreateNewProjectButton = (props: Props) => {
const muiTheme = useTheme();
const classes = useStyles();
return (
<ButtonBase
style={{
...styles.button,
width: props.fullWidth ? '100%' : undefined,
borderColor: muiTheme.palette.text.primary,
}}
classes={classes}
onClick={props.onClick}
>
<Paper square={false} background="medium" style={styles.buttonBackground}>
<Line justifyContent="center" noMargin>
<ColumnStackLayout
noMargin
justifyContent="center"
alignItems="center"
>
<Add fontSize="large" />
<Text noMargin>
<Trans>Create a project</Trans>
</Text>
</ColumnStackLayout>
</Line>
</Paper>
</ButtonBase>
);
};
export default CreateNewProjectButton;

View File

@@ -3,7 +3,6 @@ import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import { Trans, t } from '@lingui/macro';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import Text from '../../../../UI/Text';
import TextButton from '../../../../UI/TextButton';
@@ -31,9 +30,6 @@ import { ExampleStoreContext } from '../../../../AssetStore/ExampleStore/Example
import { SubscriptionSuggestionContext } from '../../../../Profile/Subscription/SubscriptionSuggestionContext';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import Add from '../../../../UI/CustomSvgIcons/Add';
import Skeleton from '@material-ui/lab/Skeleton';
import BackgroundText from '../../../../UI/BackgroundText';
import Paper from '../../../../UI/Paper';
import PlaceholderError from '../../../../UI/PlaceholderError';
import AlertMessage from '../../../../UI/AlertMessage';
import IconButton from '../../../../UI/IconButton';
@@ -43,13 +39,33 @@ import ChevronArrowRight from '../../../../UI/CustomSvgIcons/ChevronArrowRight';
import Refresh from '../../../../UI/CustomSvgIcons/Refresh';
import ProjectFileListItem from './ProjectFileListItem';
import {
getExampleAndTemplateItemsForCarousel,
getExampleAndTemplateItemsForBuildSection,
getLastModifiedInfoByProjectId,
getProjectLineHeight,
transformCloudProjectsIntoFileMetadataWithStorageProviderName,
} from './utils';
import ErrorBoundary from '../../../../UI/ErrorBoundary';
import InfoBar from '../../../../UI/Messages/InfoBar';
import CreateNewProjectButton from './CreateNewProjectButton';
import GridList from '@material-ui/core/GridList';
import type { WidthType } from '../../../../UI/Reponsive/ResponsiveWindowMeasurer';
import { GridListTile } from '@material-ui/core';
const cellSpacing = 2;
const getItemsColumns = (windowWidth: WidthType) => {
switch (windowWidth) {
case 'small':
return 1;
case 'medium':
return 3;
case 'large':
return 4;
case 'xlarge':
return 5;
default:
return 3;
}
};
const styles = {
listItem: {
@@ -62,6 +78,11 @@ const styles = {
projectSkeleton: { borderRadius: 6 },
noProjectsContainer: { padding: 10 },
refreshIconContainer: { fontSize: 20, display: 'flex', alignItems: 'center' },
grid: {
margin: 0,
// Remove the scroll capability of the grid, the scroll view handles it.
overflow: 'unset',
},
};
type Props = {|
@@ -71,7 +92,6 @@ type Props = {|
onChooseProject: () => void,
onOpenRecentFile: (file: FileMetadataAndStorageProviderName) => Promise<void>,
onOpenNewProjectSetupDialog: () => void,
onShowAllExamples: () => void,
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
onSelectPrivateGameTemplateListingData: (
privateGameTemplateListingData: PrivateGameTemplateListingData
@@ -88,7 +108,6 @@ const BuildSection = ({
canOpen,
onChooseProject,
onOpenNewProjectSetupDialog,
onShowAllExamples,
onSelectExampleShortHeader,
onSelectPrivateGameTemplateListingData,
onOpenRecentFile,
@@ -99,6 +118,10 @@ const BuildSection = ({
}: Props) => {
const { getRecentProjectFiles } = React.useContext(PreferencesContext);
const { exampleShortHeaders } = React.useContext(ExampleStoreContext);
const [
showAllGameTemplates,
setShowAllGameTemplates,
] = React.useState<boolean>(false);
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
@@ -188,29 +211,58 @@ const BuildSection = ({
return b.fileMetadata.lastModifiedDate - a.fileMetadata.lastModifiedDate;
});
const skeletonLineHeight = getProjectLineHeight(windowWidth);
const shouldShowCarousel =
!!authenticatedUser.authenticated && projectFiles.length > 0;
// Show a premium game template every 3 examples.
const examplesAndTemplatesToDisplay = React.useMemo(
() =>
getExampleAndTemplateItemsForCarousel({
authenticatedUser,
getExampleAndTemplateItemsForBuildSection({
receivedGameTemplates: authenticatedUser.receivedGameTemplates,
privateGameTemplateListingDatas,
exampleShortHeaders,
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
i18n,
numberOfItemsExclusivelyInCarousel:
!shouldShowCarousel || showAllGameTemplates ? 0 : isMobile ? 3 : 5,
numberOfItemsInCarousel:
!shouldShowCarousel || showAllGameTemplates ? 0 : isMobile ? 8 : 12,
numberOfItemsInGrid: showAllGameTemplates ? 60 : isMobile ? 16 : 20,
privateGameTemplatesPeriodicity: isMobile ? 2 : 3,
}),
[
authenticatedUser,
authenticatedUser.receivedGameTemplates,
showAllGameTemplates,
shouldShowCarousel,
exampleShortHeaders,
onSelectExampleShortHeader,
onSelectPrivateGameTemplateListingData,
privateGameTemplateListingDatas,
i18n,
isMobile,
]
);
if (showAllGameTemplates) {
return (
<SectionContainer
title={<Trans>All templates</Trans>}
backAction={() => setShowAllGameTemplates(false)}
>
<SectionRow>
<GridList
cols={getItemsColumns(windowWidth)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
{examplesAndTemplatesToDisplay.gridItems}
</GridList>
</SectionRow>
</SectionContainer>
);
}
return (
<>
<SectionContainer
@@ -236,168 +288,173 @@ const BuildSection = ({
: undefined
}
>
<SectionRow>
<Carousel
title={<Trans>Game templates</Trans>}
displayItemTitles={false}
browseAllLabel={<Trans>Browse all templates</Trans>}
onBrowseAllClick={onShowAllExamples}
items={examplesAndTemplatesToDisplay}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
roundedImages
hideArrows
/>
</SectionRow>
<SectionRow>
<ResponsiveLineStackLayout
justifyContent="space-between"
alignItems="center"
noMargin
expand
>
<Column noMargin>
<LineStackLayout noMargin alignItems="center">
<Text size="section-title">
<Trans>My projects</Trans>
</Text>
<IconButton
size="small"
onClick={refreshCloudProjects}
disabled={isRefreshing}
tooltip={t`Refresh cloud projects`}
>
<div style={styles.refreshIconContainer}>
<Refresh fontSize="inherit" />
</div>
</IconButton>
</LineStackLayout>
</Column>
<Column noMargin>
<LineStackLayout noMargin>
<RaisedButton
primary
fullWidth={!canOpen}
label={
isMobile ? (
<Trans>Create</Trans>
) : (
<Trans>Create a project</Trans>
)
}
onClick={onOpenNewProjectSetupDialog}
icon={<Add fontSize="small" />}
id="home-create-project-button"
/>
{canOpen && (
<>
<Text>
<Trans>or</Trans>
</Text>
<Spacer />
<TextButton
primary
label={
isMobile ? (
<Trans>Open</Trans>
) : (
<Trans>Open a project</Trans>
)
}
onClick={onChooseProject}
/>
</>
)}
</LineStackLayout>
</Column>
</ResponsiveLineStackLayout>
{cloudProjectsFetchingErrorLabel && (
<Line>
<PlaceholderError onRetry={onCloudProjectsChanged}>
<AlertMessage kind="warning">
{cloudProjectsFetchingErrorLabel}
</AlertMessage>
</PlaceholderError>
</Line>
)}
<Line>
<Column noMargin expand>
{!isMobile && (
<Line justifyContent="space-between">
<Column expand>
<Text color="secondary">
<Trans>File name</Trans>
</Text>
</Column>
<Column expand>
<Text color="secondary">
<Trans>Location</Trans>
</Text>
</Column>
<Column expand>
<Text color="secondary">
<Trans>Last edited</Trans>
</Text>
</Column>
{projectFiles.length === 0 && (
<SectionRow>
<GridList
cols={getItemsColumns(windowWidth)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
<GridListTile>
<Line noMargin>
<CreateNewProjectButton
onClick={onOpenNewProjectSetupDialog}
fullWidth
/>
</Line>
)}
<List>
{authenticatedUser.loginState === 'loggingIn' &&
projectFiles.length === 0 ? ( // Only show skeleton on first load
new Array(10).fill(0).map((_, index) => (
<ListItem style={styles.listItem} key={`skeleton-${index}`}>
<Line expand>
<Column expand>
<Skeleton
variant="rect"
height={skeletonLineHeight}
style={styles.projectSkeleton}
/>
</Column>
</Line>
</ListItem>
))
) : projectFiles.length > 0 ? (
projectFiles.map(file => (
<ProjectFileListItem
key={file.fileMetadata.fileIdentifier}
file={file}
currentFileMetadata={currentFileMetadata}
storageProviders={storageProviders}
isWindowWidthMediumOrLarger={!isMobile}
onOpenRecentFile={onOpenRecentFile}
lastModifiedInfo={
lastModifiedInfoByProjectId[
file.fileMetadata.fileIdentifier
]
}
onManageGame={onManageGame}
canManageGame={canManageGame}
/>
))
) : (
<ListItem style={styles.listItem}>
<Column expand>
<Paper
variant="outlined"
background="dark"
style={styles.noProjectsContainer}
>
<BackgroundText>
<Trans>No projects yet.</Trans>
</BackgroundText>
<BackgroundText>
<Trans>
Create your first project using one of our templates
or start from scratch.
</Trans>
</BackgroundText>
</Paper>
</Column>
</ListItem>
)}
</List>
</GridListTile>
</GridList>
</SectionRow>
)}
{shouldShowCarousel && (
<SectionRow>
<Carousel
title={<Trans>Ready-made games</Trans>}
displayItemTitles={false}
browseAllLabel={<Trans>Browse all templates</Trans>}
onBrowseAllClick={() => setShowAllGameTemplates(true)}
items={examplesAndTemplatesToDisplay.carouselItems}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
roundedImages
displayArrowsOnDesktop
/>
</SectionRow>
)}
{projectFiles.length > 0 && (
<SectionRow>
<ResponsiveLineStackLayout
justifyContent="space-between"
alignItems="center"
noMargin
expand
>
<Column noMargin>
<LineStackLayout noMargin alignItems="center">
<Text size="section-title">
<Trans>My projects</Trans>
</Text>
<IconButton
size="small"
onClick={refreshCloudProjects}
disabled={isRefreshing}
tooltip={t`Refresh cloud projects`}
>
<div style={styles.refreshIconContainer}>
<Refresh fontSize="inherit" />
</div>
</IconButton>
</LineStackLayout>
</Column>
<Column noMargin>
<LineStackLayout noMargin>
<RaisedButton
primary
fullWidth={!canOpen}
label={
isMobile ? (
<Trans>Create</Trans>
) : (
<Trans>Create a project</Trans>
)
}
onClick={onOpenNewProjectSetupDialog}
icon={<Add fontSize="small" />}
id="home-create-project-button"
/>
{canOpen && (
<>
<Text>
<Trans>or</Trans>
</Text>
<Spacer />
<TextButton
primary
label={
isMobile ? (
<Trans>Open</Trans>
) : (
<Trans>Open a project</Trans>
)
}
onClick={onChooseProject}
/>
</>
)}
</LineStackLayout>
</Column>
</ResponsiveLineStackLayout>
{cloudProjectsFetchingErrorLabel && (
<Line>
<PlaceholderError onRetry={onCloudProjectsChanged}>
<AlertMessage kind="warning">
{cloudProjectsFetchingErrorLabel}
</AlertMessage>
</PlaceholderError>
</Line>
)}
{projectFiles.length > 0 && (
<Line>
<Column noMargin expand>
{!isMobile && (
<Line justifyContent="space-between">
<Column expand>
<Text color="secondary">
<Trans>File name</Trans>
</Text>
</Column>
<Column expand>
<Text color="secondary">
<Trans>Location</Trans>
</Text>
</Column>
<Column expand>
<Text color="secondary">
<Trans>Last edited</Trans>
</Text>
</Column>
</Line>
)}
<List>
{projectFiles.map(file => (
<ProjectFileListItem
key={file.fileMetadata.fileIdentifier}
file={file}
currentFileMetadata={currentFileMetadata}
storageProviders={storageProviders}
isWindowWidthMediumOrLarger={!isMobile}
onOpenRecentFile={onOpenRecentFile}
lastModifiedInfo={
lastModifiedInfoByProjectId[
file.fileMetadata.fileIdentifier
]
}
onManageGame={onManageGame}
canManageGame={canManageGame}
/>
))}
</List>
</Column>
</Line>
)}
</SectionRow>
)}
<SectionRow>
<Line alignItems="center" noMargin expand>
<Column noMargin>
<Text size="section-title">
<Trans>Start with a template</Trans>
</Text>
</Column>
</Line>
<GridList
cols={getItemsColumns(windowWidth)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
{examplesAndTemplatesToDisplay.gridItems}
</GridList>
</SectionRow>
</SectionContainer>
<InfoBar

View File

@@ -1,7 +1,7 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import { getUserPublicProfilesByIds } from '../../../../Utils/GDevelopServices/User';
import { type AuthenticatedUser } from '../../../../Profile/AuthenticatedUserContext';
import { type Profile } from '../../../../Utils/GDevelopServices/Authentication';
import { type CloudProjectWithUserAccessInfo } from '../../../../Utils/GDevelopServices/Project';
import { type FileMetadataAndStorageProviderName } from '../../../../ProjectsStorage';
@@ -9,10 +9,14 @@ import { type WidthType } from '../../../../UI/Reponsive/ResponsiveWindowMeasure
import { marginsSize } from '../../../../UI/Grid';
import { sendGameTemplateInformationOpened } from '../../../../Utils/Analytics/EventSender';
import { getProductPriceOrOwnedLabel } from '../../../../AssetStore/ProductPriceTag';
import { prepareExampleShortHeaders } from '../../../../AssetStore/ExampleStore';
import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop';
import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example';
import { type PrivateGameTemplate } from '../../../../Utils/GDevelopServices/Asset';
import { type CarouselThumbnail } from '../../../../UI/Carousel';
import {
ExampleTile,
PrivateGameTemplateTile,
} from '../../../../AssetStore/ShopTiles';
export type LastModifiedInfo = {|
lastModifiedByUsername: ?string,
@@ -108,15 +112,79 @@ export const transformCloudProjectsIntoFileMetadataWithStorageProviderName = (
.filter(Boolean);
};
export const getExampleAndTemplateItemsForCarousel = ({
authenticatedUser,
const formatGameTemplateListingDataForCarousel = ({
gameTemplateListingData,
onSelectGameTemplate,
i18n,
receivedGameTemplates,
}: {|
gameTemplateListingData: PrivateGameTemplateListingData,
onSelectGameTemplate: PrivateGameTemplateListingData => void,
i18n: I18nType,
receivedGameTemplates: ?Array<PrivateGameTemplate>,
|}): CarouselThumbnail => {
const isTemplateOwned =
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
receivedGameTemplate =>
receivedGameTemplate.id === gameTemplateListingData.id
);
return {
id: gameTemplateListingData.id,
title: gameTemplateListingData.name,
thumbnailUrl: gameTemplateListingData.thumbnailUrls[0],
onClick: () => {
sendGameTemplateInformationOpened({
gameTemplateName: gameTemplateListingData.name,
gameTemplateId: gameTemplateListingData.id,
source: 'homepage',
});
onSelectGameTemplate(gameTemplateListingData);
},
overlayText: getProductPriceOrOwnedLabel({
i18n,
productListingData: gameTemplateListingData,
owned: isTemplateOwned,
}),
overlayTextPosition: 'topLeft',
};
};
const formatExampleShortHeaderForCarousel = ({
exampleShortHeader,
onSelectExample,
}: {|
exampleShortHeader: ExampleShortHeader,
onSelectExample: ExampleShortHeader => void,
|}) => {
return {
id: exampleShortHeader.id,
title: exampleShortHeader.name,
onClick: () => onSelectExample(exampleShortHeader),
thumbnailUrl: exampleShortHeader.previewImageUrls[0],
};
};
/**
* This method allocates examples and private game templates between the
* build section carousel and grid.
* `numberOfItemsExclusivelyInCarousel` controls the number of items that
* should appear in the carousel only. The rest appears in both the carousel
* and the grid.
*/
export const getExampleAndTemplateItemsForBuildSection = ({
receivedGameTemplates,
privateGameTemplateListingDatas,
exampleShortHeaders,
onSelectPrivateGameTemplateListingData,
onSelectExampleShortHeader,
i18n,
numberOfItemsExclusivelyInCarousel,
numberOfItemsInCarousel,
numberOfItemsInGrid,
privateGameTemplatesPeriodicity,
}: {|
authenticatedUser: AuthenticatedUser,
receivedGameTemplates: ?Array<PrivateGameTemplate>,
privateGameTemplateListingDatas?: ?Array<PrivateGameTemplateListingData>,
exampleShortHeaders?: ?Array<ExampleShortHeader>,
onSelectPrivateGameTemplateListingData: (
@@ -124,65 +192,101 @@ export const getExampleAndTemplateItemsForCarousel = ({
) => void,
onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void,
i18n: I18nType,
|}): Array<CarouselThumbnail> => {
const allItems: Array<CarouselThumbnail> = [];
const privateGameTemplateItems = [
...(privateGameTemplateListingDatas
? privateGameTemplateListingDatas.map(privateGameTemplateListingData => {
numberOfItemsExclusivelyInCarousel: number,
numberOfItemsInCarousel: number,
numberOfItemsInGrid: number,
privateGameTemplatesPeriodicity: number,
|}): {|
carouselItems: Array<CarouselThumbnail>,
gridItems: Array<React.Node>,
|} => {
if (!exampleShortHeaders || !privateGameTemplateListingDatas) {
return { carouselItems: [], gridItems: [] };
}
const exampleShortHeadersWithThumbnails = exampleShortHeaders.filter(
exampleShortHeader =>
!!exampleShortHeader.previewImageUrls &&
!!exampleShortHeader.previewImageUrls[0]
);
const carouselItems: Array<CarouselThumbnail> = [];
const gridItems = [];
let exampleIndex = 0;
let privateGameTemplateIndex = 0;
for (
let i = 0;
i < numberOfItemsInGrid + numberOfItemsExclusivelyInCarousel;
++i
) {
const shouldAddPrivateGameTemplate =
i % privateGameTemplatesPeriodicity ===
privateGameTemplatesPeriodicity - 1;
// At one point, we might run out of private game templates to display while
// it is assumed that we have enough examples to display. This boolean is used
// to know if we actually could add a private game template. This way, indices
// can be increased accordingly.
let privateGameTemplateActuallyAdded = false;
if (i < numberOfItemsInCarousel) {
// There should always be enough private game templates to sparsely fill the carousel.
privateGameTemplateActuallyAdded = shouldAddPrivateGameTemplate;
carouselItems.push(
shouldAddPrivateGameTemplate
? formatGameTemplateListingDataForCarousel({
i18n,
onSelectGameTemplate: onSelectPrivateGameTemplateListingData,
gameTemplateListingData:
privateGameTemplateListingDatas[privateGameTemplateIndex],
receivedGameTemplates: receivedGameTemplates,
})
: formatExampleShortHeaderForCarousel({
exampleShortHeader:
exampleShortHeadersWithThumbnails[exampleIndex],
onSelectExample: onSelectExampleShortHeader,
})
);
}
if (i >= numberOfItemsExclusivelyInCarousel) {
if (shouldAddPrivateGameTemplate) {
const privateGameTemplateListingData =
privateGameTemplateListingDatas[privateGameTemplateIndex];
if (privateGameTemplateListingData) {
const isTemplateOwned =
!!authenticatedUser.receivedGameTemplates &&
!!authenticatedUser.receivedGameTemplates.find(
!!receivedGameTemplates &&
!!receivedGameTemplates.find(
receivedGameTemplate =>
receivedGameTemplate.id === privateGameTemplateListingData.id
);
return {
id: privateGameTemplateListingData.id,
title: privateGameTemplateListingData.name,
thumbnailUrl: privateGameTemplateListingData.thumbnailUrls[0],
onClick: () => {
sendGameTemplateInformationOpened({
gameTemplateName: privateGameTemplateListingData.name,
gameTemplateId: privateGameTemplateListingData.id,
source: 'homepage',
});
onSelectPrivateGameTemplateListingData(
privateGameTemplateListingData
);
},
overlayText: getProductPriceOrOwnedLabel({
i18n,
productListingData: privateGameTemplateListingData,
owned: isTemplateOwned,
}),
overlayTextPosition: 'topLeft',
};
})
: []),
];
const exampleShortHeaderItems = [
...(exampleShortHeaders
? prepareExampleShortHeaders(exampleShortHeaders)
.map(example => ({
id: example.id,
title: example.name,
onClick: () => onSelectExampleShortHeader(example),
thumbnailUrl: example.previewImageUrls[0],
}))
.filter(exampleShortHeader => !!exampleShortHeader.thumbnailUrl)
: []),
];
for (let i = 0; i < exampleShortHeaderItems.length; ++i) {
allItems.push(exampleShortHeaderItems[i]);
if (i % 2 === 1 && privateGameTemplateItems.length > 0) {
const nextPrivateGameTemplateIndex = Math.floor(i / 2);
if (nextPrivateGameTemplateIndex < privateGameTemplateItems.length)
allItems.push(privateGameTemplateItems[nextPrivateGameTemplateIndex]);
gridItems.push(
<PrivateGameTemplateTile
privateGameTemplateListingData={privateGameTemplateListingData}
onSelect={() => {
onSelectPrivateGameTemplateListingData(
privateGameTemplateListingData
);
}}
owned={isTemplateOwned}
key={privateGameTemplateListingData.id}
/>
);
privateGameTemplateActuallyAdded = true;
}
}
if (!privateGameTemplateActuallyAdded) {
const exampleShortHeader =
exampleShortHeadersWithThumbnails[exampleIndex];
gridItems.push(
<ExampleTile
exampleShortHeader={exampleShortHeader}
onSelect={() => onSelectExampleShortHeader(exampleShortHeader)}
key={exampleShortHeader.name}
/>
);
}
}
if (privateGameTemplateActuallyAdded) privateGameTemplateIndex++;
else exampleIndex++;
}
const firstItems = allItems.slice(0, 12); // Only show 12 items.
return firstItems;
return { carouselItems, gridItems };
};

View File

@@ -27,6 +27,7 @@ const useStylesForWidget = (useDefaultDisabledStyle?: boolean) =>
root: {
border: `1px solid ${theme.palette.text.primary}`,
borderBottom: `6px solid ${theme.palette.text.primary}`,
transition: 'background-color 100ms ease',
'&:focus': {
backgroundColor: theme.palette.action.hover,
},

View File

@@ -530,7 +530,6 @@ export const HomePage = React.memo<Props>(
canOpen={canOpen}
onChooseProject={onChooseProject}
onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog}
onShowAllExamples={onOpenExampleStore}
onSelectExampleShortHeader={
onOpenExampleStoreWithExampleShortHeader
}

View File

@@ -12,12 +12,13 @@ import { useResponsiveWindowWidth } from './Reponsive/ResponsiveWindowMeasurer';
import FlatButton from './FlatButton';
import { shouldValidate } from './KeyboardShortcuts/InteractionKeys';
import AlertMessage from './AlertMessage';
import ArrowLeft from './CustomSvgIcons/ArrowLeft';
import ArrowRight from './CustomSvgIcons/ArrowRight';
import { Trans } from '@lingui/macro';
import { CorsAwareImage } from './CorsAwareImage';
import { useIsMounted } from '../Utils/UseIsMounted';
import useForceUpdate from '../Utils/UseForceUpdate';
import ChevronArrowLeft from './CustomSvgIcons/ChevronArrowLeft';
import ChevronArrowRight from './CustomSvgIcons/ChevronArrowRight';
import GDevelopThemeContext from './Theme/GDevelopThemeContext';
type OverlayTextPosition =
| 'topLeft'
@@ -51,7 +52,7 @@ type Props<ThumbnailType> = {|
displayItemTitles?: boolean,
error?: React.Node,
roundedImages?: boolean,
hideArrows?: boolean,
displayArrowsOnDesktop?: boolean,
|};
const referenceSizesByWindowSize = {
@@ -61,19 +62,12 @@ const referenceSizesByWindowSize = {
large: 150,
xlarge: 170,
},
arrowWidth: {
small: 20,
medium: 30,
large: 36,
xlarge: 42,
},
};
const cellSpacing = 12;
const titleHeight = 24;
const spacerSize = 4;
const focusItemBorderWidth = 2;
const rightArrowMargin = cellSpacing / 2; // Necessary because MUI adds a margin to GridList corresponding to cell spacing
const skeletonNumber = 6;
const randomNumbers = Array(skeletonNumber)
.fill(0)
@@ -104,7 +98,10 @@ const styles = {
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
borderRadius: 4,
},
container: { display: 'flex', position: 'relative' },
overlay: {
position: 'absolute',
borderRadius: 4,
@@ -114,6 +111,21 @@ const styles = {
},
};
const useStylesForArrowButtons = () =>
makeStyles(theme =>
createStyles({
root: {
'&:hover': {
filter:
theme.palette.type === 'dark'
? 'brightness(130%)'
: 'brightness(90%)',
},
transition: 'filter 100ms ease',
},
})
)();
const useStylesForGridList = makeStyles({
root: {
overflowX: 'scroll',
@@ -187,7 +199,7 @@ const Carousel = <ThumbnailType: CarouselThumbnail>({
error,
displayItemTitles = true,
roundedImages = false,
hideArrows = false,
displayArrowsOnDesktop = false,
}: Props<ThumbnailType>) => {
const [
shouldDisplayLeftArrow,
@@ -196,9 +208,15 @@ const Carousel = <ThumbnailType: CarouselThumbnail>({
const [
shouldDisplayRightArrow,
setShouldDisplayRightArrow,
] = React.useState<boolean>(!hideArrows);
] = React.useState<boolean>(displayArrowsOnDesktop);
const [
isMouseOverContainer,
setIsMouseOverContainer,
] = React.useState<boolean>(false);
const windowWidth = useResponsiveWindowWidth();
const isMobileScreen = windowWidth === 'small';
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const classesForArrowButtons = useStylesForArrowButtons();
const classesForGridList = useStylesForGridList();
const classesForGridListItem = useStylesForGridListItem();
const scrollView = React.useRef<?HTMLUListElement>(null);
@@ -236,7 +254,7 @@ const Carousel = <ThumbnailType: CarouselThumbnail>({
const windowSize = useResponsiveWindowWidth();
const imageHeight = referenceSizesByWindowSize.imageHeight[windowSize];
const arrowWidth = referenceSizesByWindowSize.arrowWidth[windowSize];
const arrowWidth = 30;
const cellWidth = (16 / 9) * imageHeight;
const widthUnit = cellWidth + cellSpacing;
@@ -359,7 +377,7 @@ const Carousel = <ThumbnailType: CarouselThumbnail>({
);
const onClickArrow = React.useCallback(
(direction: 'left' | 'right') => (): void => {
(direction: 'left' | 'right'): void => {
const scrollViewElement = scrollView.current;
if (!scrollViewElement) return;
const newScrollPosition = computeScroll(direction, scrollViewElement);
@@ -376,12 +394,15 @@ const Carousel = <ThumbnailType: CarouselThumbnail>({
(): void => {
const scrollViewElement = scrollView.current;
if (!scrollViewElement) return;
if (!!hideArrows) return;
if (!displayArrowsOnDesktop) return;
const isScrollAtStart = scrollViewElement.scrollLeft === 0;
const isScrollAtEnd =
scrollViewElement.scrollLeft ===
scrollViewElement.scrollWidth - scrollViewElement.clientWidth;
scrollViewElement.scrollLeft >=
scrollViewElement.scrollWidth -
scrollViewElement.clientWidth -
// margin to avoid having the arrow flickering when the tile is scaling on hover.
5;
const shouldToggleLeftArrowVisibility =
isScrollAtStart === shouldDisplayLeftArrow;
const shouldToggleRightArrowVisibility =
@@ -391,7 +412,7 @@ const Carousel = <ThumbnailType: CarouselThumbnail>({
if (shouldToggleRightArrowVisibility)
setShouldDisplayRightArrow(!shouldDisplayRightArrow);
},
[shouldDisplayLeftArrow, shouldDisplayRightArrow, hideArrows]
[shouldDisplayLeftArrow, shouldDisplayRightArrow, displayArrowsOnDesktop]
);
const handleScrollEnd = React.useCallback(
@@ -485,23 +506,35 @@ const Carousel = <ThumbnailType: CarouselThumbnail>({
</Line>
</Line>
<Line noMargin>
{!hideArrows && (
<div
style={{
...styles.arrowContainer,
width: arrowWidth,
}}
onClick={onClickArrow('left')}
>
{shouldDisplayLeftArrow && areItemsSet && <ArrowLeft />}
</div>
)}
<div
style={styles.container}
onMouseEnter={() => setIsMouseOverContainer(true)}
onMouseLeave={() => setIsMouseOverContainer(false)}
>
{displayArrowsOnDesktop &&
isMouseOverContainer &&
!isMobileScreen &&
shouldDisplayLeftArrow &&
areItemsSet && (
<div
className={classesForArrowButtons.root}
style={{
...styles.arrowContainer,
backgroundColor: gdevelopTheme.paper.backgroundColor.light,
width: arrowWidth,
height: arrowWidth,
left: 5,
zIndex: 1,
top: `calc(50% - ${Math.floor(arrowWidth / 2)}px)`,
}}
onClick={() => onClickArrow('left')}
>
<ChevronArrowLeft />
</div>
)}
<div
style={{
width: !!hideArrows
? '100%'
: `calc(100% - ${2 * arrowWidth}px - ${rightArrowMargin}px)`,
width: '100%',
}}
>
{error ? (
@@ -555,19 +588,27 @@ const Carousel = <ThumbnailType: CarouselThumbnail>({
</GridList>
)}
</div>
{!hideArrows && (
<div
style={{
...styles.arrowContainer,
width: arrowWidth,
marginLeft: rightArrowMargin,
}}
onClick={onClickArrow('right')}
>
{shouldDisplayRightArrow && areItemsSet && <ArrowRight />}
</div>
)}
</Line>
{displayArrowsOnDesktop &&
isMouseOverContainer &&
!isMobileScreen &&
shouldDisplayRightArrow &&
areItemsSet && (
<div
className={classesForArrowButtons.root}
style={{
...styles.arrowContainer,
backgroundColor: gdevelopTheme.paper.backgroundColor.light,
width: arrowWidth,
height: arrowWidth,
right: 0,
top: `calc(50% - ${Math.floor(arrowWidth / 2)}px)`,
}}
onClick={() => onClickArrow('right')}
>
<ChevronArrowRight />
</div>
)}
</div>
</Column>
);
};

View File

@@ -0,0 +1,35 @@
// @flow
import shuffle from 'lodash/shuffle';
const randomArrays: {| [size: number]: number[] |} = {};
export const getStableRandomArray = (size: number): number[] => {
if (randomArrays[size]) return randomArrays[size];
const randomArray = shuffle(new Array(size).fill(0).map((_, index) => index));
randomArrays[size] = randomArray;
return randomArrays[size];
};
export const shuffleArrayWith = <T>(
array: T[],
positionArray: number[]
): T[] => {
if (positionArray.length < array.length) {
throw new Error(
'Cannot shuffle an array with a position array of a lesser size.'
);
}
return array
.map((item, index) => ({ item, position: positionArray[index] }))
.sort((a, b) => a.position - b.position)
.map(sortingItem => sortingItem.item);
};
/**
* Shuffles array. The result will change only if the app is restarted.
*/
export const shuffleStableArray = <T>(array: T[]): T[] => {
const randomArray = getStableRandomArray(array.length);
return shuffleArrayWith(array, randomArray);
};

View File

@@ -22,6 +22,7 @@ export const LoadingWithoutTitleSkeleton = () => (
displayItemTitles={false}
browseAllLabel={<Trans>Browse all</Trans>}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
displayArrowsOnDesktop
/>
);
export const OnlyFewSquareImages = () => {
@@ -65,6 +66,7 @@ export const OnlyFewSquareImages = () => {
items={items}
displayItemTitles={false}
browseAllLink="https://www.youtube.com/c/GDevelopApp/videos"
displayArrowsOnDesktop
/>
);
};
@@ -171,6 +173,7 @@ export const RoundedImagesWithOverlay = () => {
onBrowseAllClick={() => action('Browse all button clicked')}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
roundedImages
displayArrowsOnDesktop
/>
);
};
@@ -184,7 +187,6 @@ export const WithoutArrows = () => {
onBrowseAllClick={() => action('Browse all button clicked')}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
roundedImages
hideArrows
/>
);
};
@@ -196,5 +198,6 @@ export const WithError = () => (
browseAllLabel={<Trans>Browse all</Trans>}
error={<Trans>Unexpected error</Trans>}
browseAllIcon={<ChevronArrowRight fontSize="small" />}
displayArrowsOnDesktop
/>
);