mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Improve game templates and examples display on build page (#6309)
This commit is contained in:
@@ -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]
|
||||
|
@@ -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.
|
||||
|
@@ -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;
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
@@ -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;
|
@@ -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
|
||||
|
@@ -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 };
|
||||
};
|
||||
|
@@ -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,
|
||||
},
|
||||
|
@@ -530,7 +530,6 @@ export const HomePage = React.memo<Props>(
|
||||
canOpen={canOpen}
|
||||
onChooseProject={onChooseProject}
|
||||
onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog}
|
||||
onShowAllExamples={onOpenExampleStore}
|
||||
onSelectExampleShortHeader={
|
||||
onOpenExampleStoreWithExampleShortHeader
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
35
newIDE/app/src/Utils/Random.js
Normal file
35
newIDE/app/src/Utils/Random.js
Normal 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);
|
||||
};
|
@@ -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
|
||||
/>
|
||||
);
|
||||
|
Reference in New Issue
Block a user