diff --git a/newIDE/app/src/AssetStore/AssetsHome.js b/newIDE/app/src/AssetStore/AssetsHome.js index 092650f42eab..e92d5f3c89fb 100644 --- a/newIDE/app/src/AssetStore/AssetsHome.js +++ b/newIDE/app/src/AssetStore/AssetsHome.js @@ -28,7 +28,7 @@ import { import { useDebounce } from '../Utils/UseDebounce'; import PromotionsSlideshow from '../Promotions/PromotionsSlideshow'; import { ColumnStackLayout } from '../UI/Layout'; -import { EarnBadges } from '../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges'; +import { EarnCredits } from '../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits'; const cellSpacing = 2; @@ -403,7 +403,7 @@ export const AssetsHome = React.forwardRef( {onOpenProfile && ( - { + if (item.previewImageUrls) { + // It's an example, filter out the starting points. + return !isStartingPointExampleShortHeader(item); + } + + // It's a game template, always show. + return true; +}; + type Props = {| onSelectExampleShortHeader: ExampleShortHeader => void, onSelectPrivateGameTemplateListingData: PrivateGameTemplateListingData => void, i18n: I18nType, onlyShowGames?: boolean, + hideStartingPoints?: boolean, columnsCount: number, rowToInsert?: {| row: number, element: React.Node, |}, + hideSearch?: boolean, |}; const ExampleStore = ({ @@ -56,8 +71,10 @@ const ExampleStore = ({ onSelectPrivateGameTemplateListingData, i18n, onlyShowGames, + hideStartingPoints, columnsCount, rowToInsert, + hideSearch, }: Props) => { const { receivedGameTemplates } = React.useContext(AuthenticatedUserContext); const { @@ -131,6 +148,11 @@ const ExampleStore = ({ privateGameTemplateListingData => !onlyShowGames || gameFilter(privateGameTemplateListingData) ) + .filter( + privateGameTemplateListingData => + !hideStartingPoints || + noStartingPointFilter(privateGameTemplateListingData) + ) : [], exampleShortHeaders: exampleShortHeadersSearchResults ? exampleShortHeadersSearchResults @@ -139,6 +161,11 @@ const ExampleStore = ({ exampleShortHeader => !onlyShowGames || gameFilter(exampleShortHeader) ) + .filter( + exampleShortHeader => + !hideStartingPoints || + noStartingPointFilter(exampleShortHeader) + ) : [], onSelectPrivateGameTemplateListingData: privateGameTemplateListingData => { sendGameTemplateInformationOpened({ @@ -167,6 +194,7 @@ const ExampleStore = ({ onSelectExampleShortHeader, i18n, onlyShowGames, + hideStartingPoints, ] ); @@ -183,15 +211,17 @@ const ExampleStore = ({ numberOfTilesToDisplayUntilRowToInsert ); return [ - - {firstTiles} - , + firstTiles.length > 0 ? ( + + {firstTiles} + + ) : null, rowToInsert ? ( {rowToInsert.element} ) : null, @@ -214,17 +244,19 @@ const ExampleStore = ({ return ( - - - {}} - ref={searchBarRef} - placeholder={t`Search examples`} - /> - - + {!hideSearch && ( + + + {}} + ref={searchBarRef} + placeholder={t`Search examples`} + /> + + + )} {resultTiles.length === 0 ? ( diff --git a/newIDE/app/src/AssetStore/ShopTiles.js b/newIDE/app/src/AssetStore/ShopTiles.js index d44da0068d64..d1e3488908bf 100644 --- a/newIDE/app/src/AssetStore/ShopTiles.js +++ b/newIDE/app/src/AssetStore/ShopTiles.js @@ -112,7 +112,7 @@ const styles = { }, }; -const useStylesForGridListItem = ({ disabled }: { disabled?: boolean }) => +const useStylesForGridListItem = ({ disabled }: {| disabled?: boolean |}) => makeStyles(theme => createStyles({ tile: !disabled diff --git a/newIDE/app/src/CommandPalette/CommandPalette/AutocompletePicker.js b/newIDE/app/src/CommandPalette/CommandPalette/AutocompletePicker.js index dc7999144ea3..8a01b7e49ca4 100644 --- a/newIDE/app/src/CommandPalette/CommandPalette/AutocompletePicker.js +++ b/newIDE/app/src/CommandPalette/CommandPalette/AutocompletePicker.js @@ -55,7 +55,7 @@ type Item = NamedCommand | CommandOption | GoToWikiCommand; const HitPrimaryText = ( hit: any, - { removeLastLevel }: { removeLastLevel: boolean } + { removeLastLevel }: {| removeLastLevel: boolean |} ) => { const classes = useStyles(); diff --git a/newIDE/app/src/Course/CoursePreviewBanner.js b/newIDE/app/src/Course/CoursePreviewBanner.js index e66f3d0b39b3..2e7130a9f3ae 100644 --- a/newIDE/app/src/Course/CoursePreviewBanner.js +++ b/newIDE/app/src/Course/CoursePreviewBanner.js @@ -45,7 +45,7 @@ const styles = { progress: { borderRadius: 4, height: 5 }, chip: { height: 24 }, gdevelopAvatar: { width: 20, height: 20 }, - thumbnail: { borderRadius: 4 }, + thumbnail: { borderRadius: 4, aspectRatio: '16 / 9' }, statusContainer: { display: 'flex', alignItems: 'center', diff --git a/newIDE/app/src/Debugger/Profiler/MeasuresTable.js b/newIDE/app/src/Debugger/Profiler/MeasuresTable.js index 7ef18fb810a4..d7cbf08321cd 100644 --- a/newIDE/app/src/Debugger/Profiler/MeasuresTable.js +++ b/newIDE/app/src/Debugger/Profiler/MeasuresTable.js @@ -89,7 +89,7 @@ const MeasuresTable = (props: Props) => { }); }; - const rowClassName = ({ index }: { index: number }) => { + const rowClassName = ({ index }: {| index: number |}) => { if (index < 0) { return 'tableHeaderRow'; } else { @@ -97,7 +97,11 @@ const MeasuresTable = (props: Props) => { } }; - const renderSectionNameCell = ({ rowData }: { rowData: ProfilerRowData }) => { + const renderSectionNameCell = ({ + rowData, + }: {| + rowData: ProfilerRowData, + |}) => { return (
diff --git a/newIDE/app/src/EventsBasedBehaviorEditor/index.js b/newIDE/app/src/EventsBasedBehaviorEditor/index.js index 4e301cec0734..89a6335b6ea9 100644 --- a/newIDE/app/src/EventsBasedBehaviorEditor/index.js +++ b/newIDE/app/src/EventsBasedBehaviorEditor/index.js @@ -2,7 +2,6 @@ import { Trans } from '@lingui/macro'; import { t } from '@lingui/macro'; import { I18n } from '@lingui/react'; -import { type I18n as I18nType } from '@lingui/core'; import * as React from 'react'; import TextField from '../UI/TextField'; @@ -67,7 +66,7 @@ export default function EventsBasedBehaviorEditor({ return ( - {({ i18n }: { i18n: I18nType }) => ( + {({ i18n }) => ( Node, + title: (node: {| node: SortableTreeNode |}) => Node, children: Array, expanded: boolean, @@ -700,7 +700,7 @@ export default class ThemableEventsTree extends Component< } }; - _onVisibilityToggle = ({ node }: { node: SortableTreeNode }) => { + _onVisibilityToggle = ({ node }: {| node: SortableTreeNode |}) => { const { event } = node; if (!event) return; @@ -768,7 +768,7 @@ export default class ThemableEventsTree extends Component< this.forceEventsUpdate(); }; - _getRowHeight = ({ node }: { node: ?SortableTreeNode }) => { + _getRowHeight = ({ node }: {| node: ?SortableTreeNode |}) => { if (!node) return 0; if (!node.event) return node.fixedHeight || 0; @@ -787,7 +787,7 @@ export default class ThemableEventsTree extends Component< } }; - _renderEvent = ({ node }: { node: SortableTreeNode }) => { + _renderEvent = ({ node }: {| node: SortableTreeNode |}) => { const { event, depth, disabled } = node; if (!event) return null; const { DragSourceAndDropTarget, DropTarget } = this; diff --git a/newIDE/app/src/ExportAndShare/Builds/BuildCard.js b/newIDE/app/src/ExportAndShare/Builds/BuildCard.js index 246563e78e43..0de2a075cac5 100644 --- a/newIDE/app/src/ExportAndShare/Builds/BuildCard.js +++ b/newIDE/app/src/ExportAndShare/Builds/BuildCard.js @@ -96,7 +96,7 @@ const getIcon = ( } }; -const BuildAndCreatedAt = ({ build }: { build: Build }) => ( +const BuildAndCreatedAt = ({ build }: {| build: Build |}) => ( {getIcon(build.type)} diff --git a/newIDE/app/src/ExportAndShare/ShareDialog/ExportLauncher.js b/newIDE/app/src/ExportAndShare/ShareDialog/ExportLauncher.js index bb9b7bb53b9d..c46b8041cba8 100644 --- a/newIDE/app/src/ExportAndShare/ShareDialog/ExportLauncher.js +++ b/newIDE/app/src/ExportAndShare/ShareDialog/ExportLauncher.js @@ -58,7 +58,7 @@ type Props = {| uiMode?: 'minimal', onExportLaunched?: () => void, - onExportSucceeded?: ({ build: ?Build }) => Promise, + onExportSucceeded?: ({| build: ?Build |}) => Promise, onExportErrored?: () => void, |}; diff --git a/newIDE/app/src/ExportAndShare/ShareDialog/PublishHome.js b/newIDE/app/src/ExportAndShare/ShareDialog/PublishHome.js index 8273a1d7d174..15e05becd39e 100644 --- a/newIDE/app/src/ExportAndShare/ShareDialog/PublishHome.js +++ b/newIDE/app/src/ExportAndShare/ShareDialog/PublishHome.js @@ -73,7 +73,7 @@ const styles = { }, }; -const getSectionLabel = ({ section }: { section: ExporterSection }) => { +const getSectionLabel = ({ section }: {| section: ExporterSection |}) => { switch (section) { case 'browser': return Browser; diff --git a/newIDE/app/src/GameDashboard/Feedbacks/GameFeedback.js b/newIDE/app/src/GameDashboard/Feedbacks/GameFeedback.js index 515ed45a88f7..8939685988c9 100644 --- a/newIDE/app/src/GameDashboard/Feedbacks/GameFeedback.js +++ b/newIDE/app/src/GameDashboard/Feedbacks/GameFeedback.js @@ -71,7 +71,7 @@ const pushOrCreateKey = ( const groupFeedbacks = ( i18n: I18nType, feedbacks: Array, - { build, date }: { build: boolean, date: boolean } + { build, date }: {| build: boolean, date: boolean |} ): { [buildIdOrDate: string]: Array } => { const feedbacksByBuild = feedbacks.reduce((acc, feedback) => { if (build) { diff --git a/newIDE/app/src/GameDashboard/GameAnalyticsCharts.js b/newIDE/app/src/GameDashboard/GameAnalyticsCharts.js index cad54965d5db..ec48a174ea38 100644 --- a/newIDE/app/src/GameDashboard/GameAnalyticsCharts.js +++ b/newIDE/app/src/GameDashboard/GameAnalyticsCharts.js @@ -75,7 +75,7 @@ const CustomTooltip = ({ name, unit, value, - }: { name: string, unit: ?string, value: number }, + }: {| name: string, unit: ?string, value: number |}, index ) => ( {`${name}: ${ diff --git a/newIDE/app/src/GameDashboard/GameCard.js b/newIDE/app/src/GameDashboard/GameCard.js deleted file mode 100644 index 9002c5d159e3..000000000000 --- a/newIDE/app/src/GameDashboard/GameCard.js +++ /dev/null @@ -1,270 +0,0 @@ -// @flow -import { t, Trans } from '@lingui/macro'; -import { I18n } from '@lingui/react'; -import * as React from 'react'; -import { type I18n as I18nType } from '@lingui/core'; -import { - ColumnStackLayout, - LineStackLayout, - ResponsiveLineStackLayout, -} from '../UI/Layout'; -import { - type FileMetadataAndStorageProviderName, - type StorageProvider, -} from '../ProjectsStorage'; -import FlatButton from '../UI/FlatButton'; -import Text from '../UI/Text'; -import { GameThumbnail } from './GameThumbnail'; -import { - getGameMainImageUrl, - getGameUrl, - type Game, -} from '../Utils/GDevelopServices/Game'; -import Card from '../UI/Card'; -import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext'; -import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; -import Visibility from '../UI/CustomSvgIcons/Visibility'; -import VisibilityOff from '../UI/CustomSvgIcons/VisibilityOff'; -import DollarCoin from '../UI/CustomSvgIcons/DollarCoin'; -import Cross from '../UI/CustomSvgIcons/Cross'; -import Messages from '../UI/CustomSvgIcons/Messages'; -import GameLinkAndShareIcons from './GameLinkAndShareIcons'; -import { - getStorageProviderByInternalName, - useProjectsListFor, -} from '../MainFrame/EditorContainers/HomePage/CreateSection/utils'; -import FlatButtonWithSplitMenu from '../UI/FlatButtonWithSplitMenu'; -import useOnResize from '../Utils/UseOnResize'; -import useForceUpdate from '../Utils/UseForceUpdate'; - -const styles = { - buttonsContainer: { display: 'flex', flexShrink: 0 }, - iconAndText: { display: 'flex', gap: 2, alignItems: 'flex-start' }, -}; - -type Props = {| - game: Game, - isCurrentGame: boolean, - onOpenGameManager: () => void, - storageProviders: Array, - onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, -|}; - -export const GameCard = ({ - storageProviders, - game, - isCurrentGame, - onOpenGameManager, - onOpenProject, -}: Props) => { - useOnResize(useForceUpdate()); - const projectsList = useProjectsListFor(game); - const isPublishedOnGdGames = !!game.publicWebBuildId; - const gameUrl = isPublishedOnGdGames ? getGameUrl(game) : null; - - const gameThumbnailUrl = React.useMemo(() => getGameMainImageUrl(game), [ - game, - ]); - - const { isMobile, windowSize } = useResponsiveWindowSize(); - const isWidthConstrained = windowSize === 'small' || windowSize === 'medium'; - const gdevelopTheme = React.useContext(GDevelopThemeContext); - - const renderPublicInfo = () => { - const DiscoverabilityIcon = - game.discoverable && gameUrl ? Visibility : VisibilityOff; - const AdsIcon = game.displayAdsOnGamePage ? DollarCoin : Cross; - const PlayerFeedbackIcon = game.acceptsGameComments ? Messages : Cross; - const textProps = { - color: 'secondary', - size: 'body-small', - noMargin: true, - }; - const iconProps = { - htmlColor: gdevelopTheme.text.color.secondary, - fontSize: 'small', - }; - return ( - -
- - - {game.discoverable && gameUrl ? ( - Public on gd.games - ) : gameUrl ? ( - Hidden on gd.games - ) : ( - Not published - )} - -
-
- - - {game.displayAdsOnGamePage ? ( - Ad revenue sharing on - ) : ( - Ad revenue sharing off - )} - -
-
- - - {game.acceptsGameComments ? ( - Player feedback on - ) : ( - Player feedback off - )} - -
-
- ); - }; - - const renderTitle = (i18n: I18nType) => ( - - - Published on {i18n.date(game.createdAt * 1000)} - - - {game.gameName} - - - ); - - const renderThumbnail = () => ( - - ); - - const renderButtons = ({ fullWidth }: { fullWidth: boolean }) => { - return ( -
- - Manage - ) : ( - Manage game - ) - } - onClick={onOpenGameManager} - /> - {projectsList.length === 0 ? null : projectsList.length === 1 ? ( - Opened - ) : isWidthConstrained ? ( - Open - ) : ( - Open project - ) - } - onClick={() => onOpenProject(projectsList[0])} - /> - ) : ( - Opened : Open - } - onClick={() => onOpenProject(projectsList[0])} - buildMenuTemplate={i18n => [ - ...projectsList.map(fileMetadataAndStorageProviderName => { - const name = - fileMetadataAndStorageProviderName.fileMetadata.name || '-'; - const storageProvider = getStorageProviderByInternalName( - storageProviders, - fileMetadataAndStorageProviderName.storageProviderName - ); - return { - label: i18n._( - t`${name} (${ - storageProvider ? i18n._(storageProvider.name) : '-' - })` - ), - click: () => - onOpenProject(fileMetadataAndStorageProviderName), - }; - }), - { type: 'separator' }, - { - label: i18n._(t`See all in the game dashboard`), - click: onOpenGameManager, - }, - ]} - /> - )} - -
- ); - }; - - const renderShareUrl = (i18n: I18nType) => - gameUrl ? : null; - - return ( - - {({ i18n }) => ( - - {isMobile ? ( - - {renderTitle(i18n)} - - {renderThumbnail()} - {renderPublicInfo()} - - {renderShareUrl(i18n)} - {renderButtons({ fullWidth: true })} - - ) : ( - - {renderThumbnail()} - - - {renderTitle(i18n)} - {renderButtons({ fullWidth: false })} - - {renderPublicInfo()} - {renderShareUrl(i18n)} - - - )} - - )} - - ); -}; diff --git a/newIDE/app/src/GameDashboard/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js new file mode 100644 index 000000000000..650ee5ebbaf1 --- /dev/null +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -0,0 +1,684 @@ +// @flow +import { t, Trans } from '@lingui/macro'; +import { I18n } from '@lingui/react'; +import * as React from 'react'; +import { type I18n as I18nType } from '@lingui/core'; +import { + ColumnStackLayout, + LineStackLayout, + ResponsiveLineStackLayout, +} from '../UI/Layout'; +import { + type FileMetadata, + type FileMetadataAndStorageProviderName, + type StorageProvider, +} from '../ProjectsStorage'; +import FlatButton from '../UI/FlatButton'; +import Text from '../UI/Text'; +import { GameThumbnail } from './GameThumbnail'; +import { type MenuItemTemplate } from '../UI/Menu/Menu.flow'; +import { + getGameMainImageUrl, + getGameUrl, + type Game, +} from '../Utils/GDevelopServices/Game'; +import Card from '../UI/Card'; +import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext'; +import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; +import Visibility from '../UI/CustomSvgIcons/Visibility'; +import VisibilityOff from '../UI/CustomSvgIcons/VisibilityOff'; +import DollarCoin from '../UI/CustomSvgIcons/DollarCoin'; +import Cross from '../UI/CustomSvgIcons/Cross'; +import Messages from '../UI/CustomSvgIcons/Messages'; +import GameLinkAndShareIcons from './GameLinkAndShareIcons'; +import { getStorageProviderByInternalName } from '../MainFrame/EditorContainers/HomePage/CreateSection/utils'; +import useOnResize from '../Utils/UseOnResize'; +import useForceUpdate from '../Utils/UseForceUpdate'; +import RaisedButtonWithSplitMenu from '../UI/RaisedButtonWithSplitMenu'; +import { type LastModifiedInfoByProjectId } from '../MainFrame/EditorContainers/HomePage/CreateSection/utils'; +import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; +import useAlertDialog from '../UI/Alert/useAlertDialog'; +import LastModificationInfo from '../MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo'; +import optionalRequire from '../Utils/OptionalRequire'; +import RaisedButton from '../UI/RaisedButton'; +import { Column, Line, Spacer } from '../UI/Grid'; +import ElementWithMenu from '../UI/Menu/ElementWithMenu'; +import IconButton from '../UI/IconButton'; +import ThreeDotsMenu from '../UI/CustomSvgIcons/ThreeDotsMenu'; +import WarningRound from '../UI/CustomSvgIcons/WarningRound'; +import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; +import { textEllipsisStyle } from '../UI/TextEllipsis'; +import FileWithLines from '../UI/CustomSvgIcons/FileWithLines'; +import TextButton from '../UI/TextButton'; +import { Tooltip } from '@material-ui/core'; +const electron = optionalRequire('electron'); +const path = optionalRequire('path'); + +export const getThumbnailWidth = ({ isMobile }: {| isMobile: boolean |}) => + isMobile ? undefined : Math.min(245, Math.max(130, window.innerWidth / 4)); + +const styles = { + tooltipButtonContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + }, + buttonsContainer: { + display: 'flex', + flexShrink: 0, + flexDirection: 'column', + justifyContent: 'flex-end', + }, + iconAndText: { display: 'flex', gap: 2, alignItems: 'flex-start' }, + title: { + ...textEllipsisStyle, + overflowWrap: 'break-word', + }, + projectFilesButton: { minWidth: 32 }, + fileIcon: { + width: 16, + height: 16, + }, +}; + +const locateProjectFile = (file: FileMetadataAndStorageProviderName) => { + if (!electron) return; + electron.shell.showItemInFolder( + path.resolve(file.fileMetadata.fileIdentifier) + ); +}; + +const getFileNameWithoutExtensionFromPath = (path: string) => { + // Normalize path separators for cross-platform compatibility + const normalizedPath = path.replace(/\\/g, '/'); + + // Extract file name + const fileName = normalizedPath.split('/').pop(); + + // Handle dotfiles and files without extensions + if (fileName) { + const parts = fileName.split('.'); + return parts.length > 1 ? parts.slice(0, -1).join('.') : fileName; + } + + return ''; +}; + +const getProjectItemLabel = ( + file: FileMetadataAndStorageProviderName, + storageProviders: Array, + i18n: I18nType +): string => { + const fileMetadataName = file.fileMetadata.name || '-'; + const name = + file.storageProviderName === 'LocalFile' + ? getFileNameWithoutExtensionFromPath(file.fileMetadata.fileIdentifier) || + fileMetadataName + : fileMetadataName; + const storageProvider = getStorageProviderByInternalName( + storageProviders, + file.storageProviderName + ); + return i18n._( + `${name} (${ + storageProvider ? i18n._(storageProvider.name) : file.storageProviderName + })` + ); +}; + +export type DashboardItem = {| + game?: Game, // A project can not be published, and thus not have a game. + projectFiles?: Array, // A game can have no or multiple projects. +|}; + +type Props = {| + dashboardItem: DashboardItem, + storageProviders: Array, + isCurrentProjectOpened: boolean, + onOpenGameManager: ({ game: Game, widgetToScrollTo?: 'projects' }) => void, + onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, + onUnregisterGame: () => Promise, + disabled: boolean, + canSaveProject: boolean, + askToCloseProject: () => Promise, + closeProject: () => Promise, + onSaveProject: () => Promise, + lastModifiedInfoByProjectId: LastModifiedInfoByProjectId, + currentFileMetadata: ?FileMetadata, + onRefreshGames: () => Promise, + onDeleteCloudProject: ( + file: FileMetadataAndStorageProviderName + ) => Promise, + onRegisterProject: ( + file: FileMetadataAndStorageProviderName + ) => Promise, +|}; + +const GameDashboardCard = ({ + dashboardItem, + storageProviders, + isCurrentProjectOpened, + onOpenGameManager, + onOpenProject, + onUnregisterGame, + disabled, + canSaveProject, + askToCloseProject, + closeProject, + onSaveProject, + lastModifiedInfoByProjectId, + currentFileMetadata, + onRefreshGames, + onDeleteCloudProject, + onRegisterProject, +}: Props) => { + useOnResize(useForceUpdate()); + const projectsList = React.useMemo(() => dashboardItem.projectFiles || [], [ + dashboardItem.projectFiles, + ]); + const game = dashboardItem.game; + const projectFileMetadataAndStorageProviderName = projectsList.length + ? projectsList[0] + : null; + const lastModifiedInfo = projectFileMetadataAndStorageProviderName + ? lastModifiedInfoByProjectId[ + projectFileMetadataAndStorageProviderName.fileMetadata.fileIdentifier + ] + : null; + + const authenticatedUser = React.useContext(AuthenticatedUserContext); + const { profile, onOpenLoginDialog } = authenticatedUser; + const { removeRecentProjectFile } = React.useContext(PreferencesContext); + const { + showAlert, + showConfirmation, + showDeleteConfirmation, + } = useAlertDialog(); + + const isPublishedOnGdGames = !!game && game.publicWebBuildId; + const gameUrl = isPublishedOnGdGames ? getGameUrl(game) : null; + + const gameThumbnailUrl = React.useMemo( + () => (game ? getGameMainImageUrl(game) : null), + [game] + ); + const gameName = game + ? game.gameName + : projectFileMetadataAndStorageProviderName + ? projectFileMetadataAndStorageProviderName.fileMetadata.name + : null; + + const { isMobile, windowSize, isLandscape } = useResponsiveWindowSize(); + const isSmallOrMediumScreen = + windowSize === 'small' || windowSize === 'medium'; + const gdevelopTheme = React.useContext(GDevelopThemeContext); + const itemStorageProvider = projectFileMetadataAndStorageProviderName + ? getStorageProviderByInternalName( + storageProviders, + projectFileMetadataAndStorageProviderName.storageProviderName + ) + : null; + + const renderPublicInfo = () => { + const DiscoverabilityIcon = + game && game.discoverable && gameUrl ? Visibility : VisibilityOff; + const AdsIcon = game && game.displayAdsOnGamePage ? DollarCoin : Cross; + const PlayerFeedbackIcon = + game && game.acceptsGameComments ? Messages : Cross; + const textProps = { + color: 'secondary', + size: 'body-small', + noMargin: true, + }; + const iconProps = { + htmlColor: gdevelopTheme.text.color.secondary, + fontSize: 'small', + }; + return ( + +
+ + + {game && game.discoverable && gameUrl ? ( + Public on gd.games + ) : gameUrl ? ( + Hidden on gd.games + ) : ( + Not published + )} + +
+ {game && ( +
+ + + {game.displayAdsOnGamePage ? ( + Ad revenue sharing on + ) : ( + Ad revenue sharing off + )} + +
+ )} + {game && ( +
+ + + {game.acceptsGameComments ? ( + Player feedback on + ) : ( + Player feedback off + )} + +
+ )} +
+ ); + }; + + const renderTitle = () => ( + + + {gameName || Unknown game} + + {projectsList.length > 0 && game && ( + <> + + {projectsList.length} project + ) : ( + {projectsList.length} projects + ) + } + > + {/* Button must be wrapped in a container so that the parent tooltip + can display even if the button is disabled. */} +
+ + onOpenGameManager({ game, widgetToScrollTo: 'projects' }) + } + icon={} + label={ + + {projectsList.length} + + } + disabled={disabled} + style={styles.projectFilesButton} + /> +
+
+ + )} +
+ ); + + const renderLastModification = (i18n: I18nType) => + projectFileMetadataAndStorageProviderName ? ( + Last edited:} + /> + ) : game ? ( + + + Last edited: + + + {i18n.date((game.updatedAt || 0) * 1000)} + + + ) : null; + + const renderStorageProvider = (i18n: I18nType) => { + const icon = itemStorageProvider ? ( + itemStorageProvider.renderIcon ? ( + itemStorageProvider.renderIcon({ + size: 'small', + }) + ) : null + ) : ( + + ); + const name = itemStorageProvider ? ( + i18n._(itemStorageProvider.name) + ) : isCurrentProjectOpened ? ( + Project not saved + ) : ( + Project not found + ); + + return ( + + {icon && ( + <> + {icon} + + + )} + + {name} + + + ); + }; + + const renderThumbnail = () => ( + + ); + + const buildOpenProjectContextMenu = ( + i18n: I18nType + ): Array => { + const actions = []; + if (projectsList.length > 1) { + actions.push( + ...[ + ...projectsList.map(fileMetadataAndStorageProviderName => { + return { + label: getProjectItemLabel( + fileMetadataAndStorageProviderName, + storageProviders, + i18n + ), + click: () => onOpenProject(fileMetadataAndStorageProviderName), + }; + }), + ] + ); + + if (game) { + actions.push( + ...[ + { type: 'separator' }, + { + label: i18n._(t`See all in the game dashboard`), + click: () => onOpenGameManager({ game }), + }, + ] + ); + } + } + + return actions; + }; + + const renderAdditionalActions = () => { + return ( + + + + } + buildMenuTemplate={(i18n: I18nType) => { + const actions = []; + + // Close action + if (isCurrentProjectOpened) { + actions.push({ + label: i18n._(t`Close project`), + click: async () => { + await askToCloseProject(); + }, + }); + } + + // Management actions. + if (projectsList.length === 0) { + // No management possible, it's a game without a project found. + } + + if (projectsList.length === 1) { + const file = projectsList[0]; + if (file && file.storageProviderName === 'LocalFile') { + actions.push({ + label: i18n._(t`Show in local folder`), + click: () => locateProjectFile(file), + }); + } + } + + if (projectsList.length > 1) { + // If there are multiple projects, suggest opening the game dashboard. + actions.push({ + label: i18n._(t`See all projects`), + click: game ? () => onOpenGameManager({ game }) : undefined, + }); + } + + // Delete actions. + // Don't allow removing project if opened, as it would not result in any change in the list. + // (because an opened project is always displayed) + if (isCurrentProjectOpened || projectsList.length > 1) { + // No delete action possible. + } else { + if (actions.length > 0) { + actions.push({ + type: 'separator', + }); + } + + actions.push({ + label: i18n._(t`Delete`), + click: async () => { + // Extract word translation to ensure it is not wrongly translated in the sentence. + const translatedConfirmText = i18n._(t`delete`); + + const answer = await showDeleteConfirmation({ + title: t`Delete game`, + message: t`Your game will be deleted. This action is irreversible. Do you want to continue?`, + confirmButtonLabel: t`Delete game`, + fieldMessage: t`To confirm, type "${translatedConfirmText}"`, + confirmText: translatedConfirmText, + }); + if (!answer) return; + + // If the game is registered, unregister it. + // If it fails, this will throw, to prevent deleting a game with leaderboards or not owned. + if (game) { + try { + await onUnregisterGame(); + } catch (error) { + console.error('Unable to unregister the game.', error); + // Alert is handled by onUnregisterGame. Just ensure we don't continue. + return; + } + } + + // If there is a project file (local or cloud), remove it. + // There can be only one here, thanks to the check above. + const file = projectsList[0]; + if (file) { + if (file.storageProviderName === 'Cloud') { + await onDeleteCloudProject(file); + } else { + await removeRecentProjectFile(file); + } + } + + await onRefreshGames(); + }, + }); + } + + return actions; + }} + /> + ); + }; + + const onManageGame = React.useCallback( + async () => { + if (game) { + onOpenGameManager({ game }); + return; + } else { + if (!profile) { + onOpenLoginDialog(); + return; + } + const answer = await showConfirmation({ + title: t`Manage game online`, + message: t`This game is not registered online. Do you want to register it to access the online features?`, + confirmButtonLabel: t`Continue`, + }); + if (!answer) return; + + const registeredGame = await onRegisterProject(projectsList[0]); + if (!registeredGame) return; + + await onRefreshGames(); + onOpenGameManager({ game: registeredGame }); + } + }, + [ + game, + onOpenGameManager, + showConfirmation, + onRegisterProject, + projectsList, + onRefreshGames, + onOpenLoginDialog, + profile, + ] + ); + + const renderButtons = ({ fullWidth }: {| fullWidth: boolean |}) => { + const openProjectLabel = isCurrentProjectOpened ? ( + Save + ) : ( + Open + ); + const mainAction = isCurrentProjectOpened + ? onSaveProject + : projectsList.length > 0 + ? () => onOpenProject(projectsList[0]) + : () => { + showAlert({ + title: t`No project to open`, + message: t`Looks like your project isn't there!${'\n\n'}You may be using a different computer or opening GDevelop on the web and your project is saved locally.`, + }); + }; + + return ( +
+ + Manage} + onClick={onManageGame} + disabled={disabled} + /> + {projectsList.length < 2 ? ( + + ) : ( + buildOpenProjectContextMenu(i18n)} + disabled={disabled || (isCurrentProjectOpened && !canSaveProject)} + /> + )} + +
+ ); + }; + + const renderShareUrl = (i18n: I18nType) => + gameUrl ? ( + + ) : null; + + return ( + + {({ i18n }) => ( + + {isMobile && !isLandscape ? ( + + + + {renderTitle()} + {renderAdditionalActions()} + + {renderLastModification(i18n)} + + + {renderThumbnail()} + {renderPublicInfo()} + + {renderShareUrl(i18n)} + {renderButtons({ fullWidth: true })} + + ) : ( + + {renderThumbnail()} + + + + + {renderLastModification(i18n)} + {renderTitle()} + + + {!isSmallOrMediumScreen && renderStorageProvider(i18n)} + {renderAdditionalActions()} + + + {renderPublicInfo()} + + + {renderShareUrl(i18n)} + {renderButtons({ fullWidth: false })} + + + + )} + + )} + + ); +}; + +export default GameDashboardCard; diff --git a/newIDE/app/src/GameDashboard/GameHeader.js b/newIDE/app/src/GameDashboard/GameHeader.js index 97ec7a22988b..a143a62c899a 100644 --- a/newIDE/app/src/GameDashboard/GameHeader.js +++ b/newIDE/app/src/GameDashboard/GameHeader.js @@ -45,7 +45,7 @@ const GameHeader = ({ onPublishOnGdGames, }: Props) => { useOnResize(useForceUpdate()); - const { isMobile } = useResponsiveWindowSize(); + const { isMobile, isLandscape } = useResponsiveWindowSize(); const gdevelopTheme = React.useContext(GDevelopThemeContext); const gameMainImageUrl = getGameMainImageUrl(game); @@ -64,7 +64,11 @@ const GameHeader = ({ fontSize: 'small', }; return ( - +
@@ -103,7 +107,7 @@ const GameHeader = ({ const renderTitle = (i18n: I18nType) => ( - Published on {i18n.date(game.createdAt * 1000)} + Created on {i18n.date(game.createdAt * 1000)} {game.gameName} @@ -153,7 +157,7 @@ const GameHeader = ({ ); - if (isMobile) { + if (isMobile && !isLandscape) { return ( {({ i18n }) => ( diff --git a/newIDE/app/src/GameDashboard/GameLinkAndShareIcons.js b/newIDE/app/src/GameDashboard/GameLinkAndShareIcons.js index a367daec3db6..578cba8862e3 100644 --- a/newIDE/app/src/GameDashboard/GameLinkAndShareIcons.js +++ b/newIDE/app/src/GameDashboard/GameLinkAndShareIcons.js @@ -4,13 +4,11 @@ import * as React from 'react'; import { LineStackLayout } from '../UI/Layout'; import SocialShareButtons from '../UI/ShareDialog/SocialShareButtons'; import ShareLink from '../UI/ShareDialog/ShareLink'; -import { marginsSize } from '../UI/Grid'; +import { Spacer } from '../UI/Grid'; const styles = { buttonsContainer: { flexShrink: 0, - marginTop: marginsSize, - marginBottom: marginsSize, }, columnContainer: { display: 'grid' }, }; @@ -42,6 +40,7 @@ const GameLinkAndShareIcons = ({ url, display }: Props) => { return ( + {display === 'column' && }
diff --git a/newIDE/app/src/GameDashboard/GameRegistration.js b/newIDE/app/src/GameDashboard/GameRegistration.js index 9d14d93adf35..38312cf10f3c 100644 --- a/newIDE/app/src/GameDashboard/GameRegistration.js +++ b/newIDE/app/src/GameDashboard/GameRegistration.js @@ -180,9 +180,9 @@ export const GameRegistration = ({ return ( - The project currently opened is registered online but you don't have - access to it. Ask the original owner of the game to share it with you - to be able to manage it. + This game is registered online but you don't have access to it. Ask + the owner of the game to add your account to the list of owners to be + able to manage it. ); diff --git a/newIDE/app/src/GameDashboard/GameThumbnail.js b/newIDE/app/src/GameDashboard/GameThumbnail.js index 7064c3b80dd2..2c25756b9544 100644 --- a/newIDE/app/src/GameDashboard/GameThumbnail.js +++ b/newIDE/app/src/GameDashboard/GameThumbnail.js @@ -16,10 +16,14 @@ import { import { uploadBlobFile } from '../ExportAndShare/BrowserExporters/BrowserFileUploader'; import { CorsAwareImage } from '../UI/CorsAwareImage'; +const defaultThumbnailWidth = 272; +const mobileThumbnailWidth = 150; + const styles = { image: { display: 'block', objectFit: 'scale-down', // Match gd.games format. + aspectRatio: '16 / 9', }, fullWidthContainer: { width: '100%', @@ -27,14 +31,6 @@ const styles = { height: 'auto', justifyContent: 'center', }, - thumbnail: { - aspectRatio: '16 / 9', - width: 272, - }, - mobileThumbnail: { - aspectRatio: '16 / 9', - width: 150, - }, fullWidthThumbnail: { width: '100%', height: 'auto', @@ -159,7 +155,11 @@ export const GameThumbnail = ({ } }; - const thumbnailWidth = width ? width : isMobile && !isLandscape ? 150 : 272; + const thumbnailWidth = width + ? width + : isMobile && !isLandscape + ? mobileThumbnailWidth + : defaultThumbnailWidth; const thumbnailHeight = Math.floor(thumbnailWidth / (16 / 9)); return ( @@ -182,17 +182,16 @@ export const GameThumbnail = ({ src={thumbnailUrl} style={{ ...styles.image, - ...(isMobile && !isLandscape - ? fullWidthOnMobile - ? styles.fullWidthThumbnail - : styles.mobileThumbnail - : styles.thumbnail), + width: thumbnailWidth, + ...(isMobile && !isLandscape && fullWidthOnMobile + ? styles.fullWidthThumbnail + : {}), }} alt={gameName} /> ) : ( - No thumbnail set + No thumbnail )} diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 5269f1f15f4e..a1cdee0994ac 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -1,14 +1,23 @@ // @flow import * as React from 'react'; import Fuse from 'fuse.js'; +import { I18n } from '@lingui/react'; +import { type I18n as I18nType } from '@lingui/core'; import { Trans, t } from '@lingui/macro'; import { type Game } from '../Utils/GDevelopServices/Game'; -import { GameCard } from './GameCard'; -import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout'; +import GameDashboardCard, { + getThumbnailWidth, + type DashboardItem, +} from './GameDashboardCard'; +import { + ColumnStackLayout, + LineStackLayout, + ResponsiveLineStackLayout, +} from '../UI/Layout'; import SearchBar from '../UI/SearchBar'; import { useDebounce } from '../Utils/UseDebounce'; import { - getFuseSearchQueryForSimpleArray, + getFuseSearchQueryForMultipleKeys, sharedFuseConfiguration, } from '../UI/Search/UseSearchStructuredItem'; import IconButton from '../UI/IconButton'; @@ -20,99 +29,345 @@ import Paper from '../UI/Paper'; import BackgroundText from '../UI/BackgroundText'; import SelectOption from '../UI/SelectOption'; import SearchBarSelectField from '../UI/SearchBarSelectField'; -import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; import { type FileMetadataAndStorageProviderName, type StorageProvider, + type FileMetadata, } from '../ProjectsStorage'; +import RaisedButton from '../UI/RaisedButton'; +import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; +import Add from '../UI/CustomSvgIcons/Add'; +import { + getLastModifiedInfoByProjectId, + useProjectsListFor, +} from '../MainFrame/EditorContainers/HomePage/CreateSection/utils'; +import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; +import Refresh from '../UI/CustomSvgIcons/Refresh'; +import optionalRequire from '../Utils/OptionalRequire'; +import TextButton from '../UI/TextButton'; +import Skeleton from '@material-ui/lab/Skeleton'; +import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; +const electron = optionalRequire('electron'); + +const isDesktop = !!electron; const pageSize = 10; -const styles = { noGameMessageContainer: { padding: 10 } }; +const styles = { + noGameMessageContainer: { padding: 10 }, + refreshIconContainer: { fontSize: 20, display: 'flex', alignItems: 'center' }, + gameLoadingSkeleton: { + // Display a skeleton with the same aspect and border as the game card: + borderRadius: 8, + }, +}; + +export type GamesDashboardOrderBy = + | 'totalSessions' + | 'weeklySessions' + | 'lastModifiedAt'; + +const totalSessionsSort = ( + itemA: DashboardItem, + itemB: DashboardItem +): number => + ((itemB.game && itemB.game.cachedTotalSessionsCount) || 0) - + ((itemA.game && itemA.game.cachedTotalSessionsCount) || 0); + +const lastWeekSessionsSort = ( + itemA: DashboardItem, + itemB: DashboardItem +): number => + ((itemB.game && itemB.game.cachedLastWeekSessionsCount) || 0) - + ((itemA.game && itemA.game.cachedLastWeekSessionsCount) || 0); + +const getDashboardItemLastModifiedAt = (item: DashboardItem): number => { + // First prioritize the projects that have been modified recently, if any. + if (item.projectFiles && item.projectFiles.length > 0) { + return Math.max( + ...item.projectFiles.map( + projectFile => projectFile.fileMetadata.lastModifiedDate || 0 + ) + ); + } + // Then the game, if any. + return (item.game && (item.game.updatedAt || 0) * 1000) || 0; +}; + +const lastModifiedAtSort = ( + itemA: DashboardItem, + itemB: DashboardItem +): number => { + return ( + getDashboardItemLastModifiedAt(itemB) - + getDashboardItemLastModifiedAt(itemA) + ); +}; + +const areDashboardItemsEqual = ( + itemA: DashboardItem, + itemB: DashboardItem +): boolean => { + const gameA = itemA.game; + const gameB = itemB.game; + if (gameA && gameB) return gameA.id === gameB.id; + const projectFilesA = itemA.projectFiles; + const projectFilesB = itemB.projectFiles; + if (projectFilesA && projectFilesB) { + if (projectFilesA.length !== projectFilesB.length) return false; + return projectFilesA.every((projectFile, index) => { + const otherProjectFile = projectFilesB[index]; + return ( + projectFile.fileMetadata.gameId === + otherProjectFile.fileMetadata.gameId && + projectFile.fileMetadata.fileIdentifier === + otherProjectFile.fileMetadata.fileIdentifier + ); + }); + } + return false; +}; -const getGamesToDisplay = ({ +const getDashboardItemsToDisplay = ({ project, - games, + currentFileMetadata, + allDashboardItems, searchText, searchClient, currentPage, orderBy, }: {| project: ?gdProject, - games: Array, + currentFileMetadata: ?FileMetadata, + allDashboardItems: ?Array, searchText: string, searchClient: Fuse, currentPage: number, - orderBy: 'createdAt' | 'totalSessions' | 'weeklySessions', -|}): Array => { + orderBy: GamesDashboardOrderBy, +|}): ?Array => { + if (!allDashboardItems) return null; + let itemsToDisplay: DashboardItem[] = allDashboardItems.filter( + item => + // First, filter out unsaved games, unless they are the opened project. + !item.game || + item.game.savedStatus !== 'draft' || + (project && item.game.id === project.getProjectUuid()) + ); + if (searchText) { + // If there is a search, just return those items, ordered by the search relevance. const searchResults = searchClient.search( - getFuseSearchQueryForSimpleArray(searchText) + getFuseSearchQueryForMultipleKeys(searchText, [ + 'game.gameName', + 'projectFiles.fileMetadata.name', + ]) + ); + itemsToDisplay = searchResults.map(result => result.item); + } else { + // If there is no search, sort the items by the selected order. + itemsToDisplay = + orderBy === 'totalSessions' + ? itemsToDisplay.sort(totalSessionsSort) + : orderBy === 'weeklySessions' + ? itemsToDisplay.sort(lastWeekSessionsSort) + : orderBy === 'lastModifiedAt' + ? itemsToDisplay.sort(lastModifiedAtSort) + : itemsToDisplay; + + // If a project is opened, no search is done, and sorted by last modified date, + // then the opened project should be displayed first. + if (project && orderBy === 'lastModifiedAt') { + const currentProjectId = project.getProjectUuid(); + const currentFileIdentifier = currentFileMetadata + ? currentFileMetadata.fileIdentifier + : null; + const dashboardItemLinkedToOpenedProject = allDashboardItems.find( + dashboardItem => + // Either it's a registered game. + (dashboardItem.game && dashboardItem.game.id === currentProjectId) || + // Or it's just a project file. + (dashboardItem.projectFiles && + dashboardItem.projectFiles.some( + projectFile => + projectFile.fileMetadata.gameId === currentProjectId && + projectFile.fileMetadata.fileIdentifier === + currentFileIdentifier + )) + ); + if (dashboardItemLinkedToOpenedProject) { + itemsToDisplay = [ + dashboardItemLinkedToOpenedProject, + ...itemsToDisplay.filter( + item => + !areDashboardItemsEqual(item, dashboardItemLinkedToOpenedProject) + ), + ]; + } else { + // In case a project is opened but not found in the list of recent projects, + // either it's been saved but then removed from the list. + // or it's never been saved. + // In this case, add the opened project first in the list. + const fileMetadata: FileMetadata = currentFileMetadata || { + fileIdentifier: 'unsaved-project', + name: project.getName(), + gameId: project.getProjectUuid(), + }; + const openedProjectDashboardItem: DashboardItem = { + projectFiles: [ + { + fileMetadata, + // We're not sure about the storage provider, so we leave it empty. + storageProviderName: '', + }, + ], + }; + itemsToDisplay = [openedProjectDashboardItem, ...itemsToDisplay]; + } + } + + // Finally, if there is no search, paginate the results. + itemsToDisplay = itemsToDisplay.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize ); - return searchResults.map(result => result.item); } - const projectUuid = project ? project.getProjectUuid() : null; - const thisGame = games.find(game => !!projectUuid && game.id === projectUuid); - - // Do the ordering here, client-side, as we receive all the games from the API. - const orderedGames = - orderBy === 'totalSessions' - ? [...games].sort( - (a, b) => - (b.cachedTotalSessionsCount || 0) - - (a.cachedTotalSessionsCount || 0) - ) - : orderBy === 'weeklySessions' - ? [...games].sort( - (a, b) => - (b.cachedLastWeekSessionsCount || 0) - - (a.cachedLastWeekSessionsCount || 0) - ) - : thisGame - ? [thisGame, ...games.filter(game => game.id !== thisGame.id)] - : games; - return orderedGames.slice( - currentPage * pageSize, - (currentPage + 1) * pageSize - ); + return itemsToDisplay; }; type Props = {| storageProviders: Array, project: ?gdProject, - games: Array, + currentFileMetadata: ?FileMetadata, + games: ?Array, onRefreshGames: () => Promise, - onOpenGameId: (gameId: ?string) => void, + onOpenGameManager: ({ + game: Game, + widgetToScrollTo?: 'projects', + }) => void, onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, + onUnregisterGame: ( + gameId: string, + i18n: I18nType, + options?: { skipConfirmation: boolean, throwOnError: boolean } + ) => Promise, + onRegisterProject: ( + file: FileMetadataAndStorageProviderName + ) => Promise, + isUpdatingGame: boolean, + canOpen: boolean, + onOpenNewProjectSetupDialog: () => void, + onChooseProject: () => void, + closeProject: () => Promise, + askToCloseProject: () => Promise, + onSaveProject: () => Promise, + canSaveProject: boolean, + onDeleteCloudProject: ( + i18n: I18nType, + file: FileMetadataAndStorageProviderName, + options?: { skipConfirmation: boolean } + ) => Promise, + // Controls + currentPage: number, + setCurrentPage: (currentPage: number) => void, + searchText: string, + setSearchText: (searchText: string) => void, |}; const GamesList = ({ project, + currentFileMetadata, games, onRefreshGames, - onOpenGameId, + onOpenGameManager, onOpenProject, + onUnregisterGame, + onRegisterProject, + onDeleteCloudProject, + isUpdatingGame, storageProviders, + canOpen, + onOpenNewProjectSetupDialog, + onChooseProject, + closeProject, + askToCloseProject, + onSaveProject, + canSaveProject, + // Make the page controlled, so that it can be saved when navigating to a game. + currentPage, + setCurrentPage, + searchText, + setSearchText, }: Props) => { - const { values, setGamesListOrderBy } = React.useContext(PreferencesContext); - const [searchText, setSearchText] = React.useState(''); - const [currentPage, setCurrentPage] = React.useState(0); - const { gamesListOrderBy: orderBy } = values; + const { cloudProjects, profile, onCloudProjectsChanged } = React.useContext( + AuthenticatedUserContext + ); + const { + values: { gamesDashboardOrderBy: orderBy }, + setGamesDashboardOrderBy, + } = React.useContext(PreferencesContext); + + const { isMobile } = useResponsiveWindowSize(); + const gameThumbnailWidth = getThumbnailWidth({ isMobile }); + + const allRecentProjectFiles = useProjectsListFor(null); + const allDashboardItems: ?(DashboardItem[]) = React.useMemo( + () => { + if (!games) return null; + const projectFilesWithGame = games.map(game => { + const projectFiles = allRecentProjectFiles.filter( + file => file.fileMetadata.gameId === game.id + ); + return { game, projectFiles }; + }); + const projectFilesWithoutGame = allRecentProjectFiles + .filter( + file => !games.find(game => game.id === file.fileMetadata.gameId) + ) + .map(file => ({ projectFiles: [file] })); + return [...projectFilesWithGame, ...projectFilesWithoutGame]; + }, + [games, allRecentProjectFiles] + ); + + const totalNumberOfPages = allDashboardItems + ? Math.ceil(allDashboardItems.length / pageSize) + : 1; + const onCurrentPageChange = React.useCallback( + newPage => { + const minPage = 1; + const maxPage = totalNumberOfPages; + if (newPage < minPage) { + setCurrentPage(minPage); + } else if (newPage > maxPage) { + setCurrentPage(maxPage); + } else { + setCurrentPage(newPage); + } + }, + [setCurrentPage, totalNumberOfPages] + ); const searchClient = React.useMemo( () => - new Fuse(games, { + new Fuse(allDashboardItems || [], { ...sharedFuseConfiguration, - keys: [{ name: 'gameName', weight: 1 }], + keys: [ + { name: 'game.gameName', weight: 1 }, + { name: 'projectFiles.fileMetadata.name', weight: 1 }, + ], }), - [games] + [allDashboardItems] ); - const [displayedGames, setDisplayedGames] = React.useState>( - getGamesToDisplay({ + const [ + displayedDashboardItems, + setDisplayedDashboardItems, + ] = React.useState>( + getDashboardItemsToDisplay({ project, - games, + currentFileMetadata, + allDashboardItems, searchText, searchClient, currentPage, @@ -120,12 +375,13 @@ const GamesList = ({ }) ); - const getGamesToDisplayDebounced = useDebounce( + const getDashboardItemsToDisplayDebounced = useDebounce( () => { - setDisplayedGames( - getGamesToDisplay({ + setDisplayedDashboardItems( + getDashboardItemsToDisplay({ project, - games, + currentFileMetadata, + allDashboardItems, searchText, searchClient, currentPage, @@ -139,115 +395,283 @@ const GamesList = ({ searchText ? 250 : 150 ); - // Refresh games to display when: - // - search text changes (user input) - // - games change (refresh following an update for instance) - // - user changes page - React.useEffect(getGamesToDisplayDebounced, [ - getGamesToDisplayDebounced, - searchText, - games, - currentPage, - orderBy, + // Refresh games to display, depending on a few parameters. + React.useEffect(getDashboardItemsToDisplayDebounced, [ + getDashboardItemsToDisplayDebounced, + searchText, // search text changes (user input) + games, // games change (when updating a game for instance) + currentPage, // user changes page + orderBy, // user changes order + currentFileMetadata, // opened project changes (when opening or closing a project from here) + allRecentProjectFiles.length, // list of recent projects changes (when a project is removed from list) + project, // opened project changes (when closing a project from here) ]); const projectUuid = project ? project.getProjectUuid() : null; - return ( - - - - Published games - - - - - // $FlowFixMe - setGamesListOrderBy(value) + const [isRefreshing, setIsRefreshing] = React.useState(false); + const refreshGamesList = React.useCallback( + async () => { + if (isRefreshing) return; + try { + setIsRefreshing(true); + await Promise.all([onCloudProjectsChanged(), onRefreshGames()]); + } finally { + // Wait a bit to avoid spam as we don't have a "loading" state. + setTimeout(() => setIsRefreshing(false), 2000); + } + }, + [onCloudProjectsChanged, isRefreshing, onRefreshGames] + ); + + const [ + lastModifiedInfoByProjectId, + setLastModifiedInfoByProjectId, + ] = React.useState({}); + // Look at projects where lastCommittedBy is not the current user (cloud projects only), and fetch + // public profiles of the users that have modified them. + React.useEffect( + () => { + const updateModificationInfoByProjectId = async () => { + if (!cloudProjects || !profile) return; + + const _lastModifiedInfoByProjectId = await getLastModifiedInfoByProjectId( + { + cloudProjects, + profile, } - > - - - - - - - {}} - placeholder={t`Search by name`} - autoFocus="desktop" - /> - - setCurrentPage(currentPage => currentPage - 1)} - disabled={!!searchText || currentPage === 0} - size="small" - > - - - - {searchText ? 1 : currentPage + 1} - - setCurrentPage(currentPage => currentPage + 1)} - disabled={ - !!searchText || (currentPage + 1) * pageSize >= games.length - } - size="small" - > - - - - - {displayedGames.length > 0 ? ( - displayedGames.map(game => ( - { - onOpenGameId(game.id); - }} - onOpenProject={onOpenProject} - /> - )) - ) : !!searchText ? ( - - - - No game matching your search. - - - - ) : null} - + ); + setLastModifiedInfoByProjectId(_lastModifiedInfoByProjectId); + }; + + updateModificationInfoByProjectId(); + }, + [cloudProjects, profile] + ); + + const shouldShowOpenProject = + canOpen && + // Only show on large screens. + !isMobile && + // Only show on desktop as otherwise, cloud projects are the only ones that can be opened. + isDesktop; + + return ( + + {({ i18n }) => ( + + + + + Games + + +
+ +
+
+
+ + Create + ) : ( + Create new game + ) + } + onClick={onOpenNewProjectSetupDialog} + icon={} + id="home-create-project-button" + disabled={isUpdatingGame} + /> + {shouldShowOpenProject && ( + Open + ) : ( + Open a project + ) + } + onClick={onChooseProject} + disabled={isUpdatingGame} + /> + )} + +
+ {allDashboardItems && allDashboardItems.length > 0 && ( + + + {}} + placeholder={t`Search by name`} + /> + + + + // $FlowFixMe + setGamesDashboardOrderBy(value) + } + > + + + + + + onCurrentPageChange(currentPage - 1)} + disabled={!!searchText || currentPage === 1} + size="small" + > + + + + {searchText || totalNumberOfPages === 1 + ? 1 + : `${currentPage}/${totalNumberOfPages}`} + + onCurrentPageChange(currentPage + 1)} + disabled={!!searchText || currentPage >= totalNumberOfPages} + size="small" + > + + + + + + )} + {!displayedDashboardItems && + Array.from({ length: pageSize }).map((_, i) => ( + + + + ))} + {displayedDashboardItems && displayedDashboardItems.length > 0 ? ( + displayedDashboardItems + .map((dashboardItem, index) => { + const game = dashboardItem.game; + const projectFileMetadataAndStorageProviderName = dashboardItem.projectFiles + ? dashboardItem.projectFiles[0] + : null; + + const key = game + ? game.id + : projectFileMetadataAndStorageProviderName + ? `${projectFileMetadataAndStorageProviderName.fileMetadata + .name || 'project'}-${index}` + : ''; + const isCurrentProjectOpened = + (!!projectUuid && (!!game && game.id === projectUuid)) || + (!!projectFileMetadataAndStorageProviderName && + projectFileMetadataAndStorageProviderName.fileMetadata + .gameId === projectUuid); + + return ( + { + if (!game) return; + await onUnregisterGame(game.id, i18n, { + // Unregistering is done as part of the project deletion, so no need to ask for extra confirmation. + skipConfirmation: true, + // Ensure we throw to stop the project deletion if an error occurs. + throwOnError: true, + }); + }} + onRegisterProject={onRegisterProject} + disabled={isUpdatingGame} + canSaveProject={canSaveProject} + askToCloseProject={askToCloseProject} + closeProject={closeProject} + onSaveProject={onSaveProject} + lastModifiedInfoByProjectId={lastModifiedInfoByProjectId} + currentFileMetadata={currentFileMetadata} + onRefreshGames={refreshGamesList} + onDeleteCloudProject={async ( + file: FileMetadataAndStorageProviderName + ) => { + await onDeleteCloudProject(i18n, file, { + skipConfirmation: true, + }); + }} + /> + ); + }) + .filter(Boolean) + ) : !!searchText ? ( + + + + No game matching your search. + + + + ) : null} +
+ )} +
); }; diff --git a/newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js b/newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js index 432b213565d8..1b0ec778aaa8 100644 --- a/newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js +++ b/newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js @@ -73,14 +73,6 @@ import Paper from '../../UI/Paper'; import SwitchHorizontal from '../../UI/CustomSvgIcons/SwitchHorizontal'; import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors'; -type Props = {| - onLoading: boolean => void, - project?: gdProject, - leaderboardIdToSelectAtOpening?: string, -|}; - -type ContainerProps = {| ...Props, gameId: string |}; - type ApiError = {| action: | 'entriesFetching' @@ -187,6 +179,12 @@ const getSortOrderText = (currentLeaderboard: Leaderboard) => { return Higher is better; }; +type Props = {| + onLoading: boolean => void, + project?: gdProject, + leaderboardIdToSelectAtOpening?: string, +|}; + export const LeaderboardAdmin = ({ onLoading, project, @@ -1156,6 +1154,8 @@ export const LeaderboardAdmin = ({ ); }; +type ContainerProps = {| ...Props, gameId: string |}; + const LeaderboardAdminContainer = ({ gameId, ...otherProps diff --git a/newIDE/app/src/GameDashboard/Monetization/UserEarnings.js b/newIDE/app/src/GameDashboard/Monetization/UserEarnings.js deleted file mode 100644 index 07738e7df3ea..000000000000 --- a/newIDE/app/src/GameDashboard/Monetization/UserEarnings.js +++ /dev/null @@ -1,263 +0,0 @@ -// @flow -import { Trans } from '@lingui/macro'; -import * as React from 'react'; - -import { LineStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout'; -import Text from '../../UI/Text'; -import { Column, Line, Spacer } from '../../UI/Grid'; -import Card from '../../UI/Card'; -import BackgroundText from '../../UI/BackgroundText'; -import Link from '../../UI/Link'; -import Window from '../../Utils/Window'; -import RaisedButton from '../../UI/RaisedButton'; -import Coin from '../../Credits/Icons/Coin'; -import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; -import PlaceholderError from '../../UI/PlaceholderError'; -import Bank from '../../UI/CustomSvgIcons/Bank'; -import { Tooltip } from '@material-ui/core'; -import CreditOutDialog from './CashOutDialog'; -import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; - -const styles = { - separator: { - height: 50, - }, - buttonContainer: { - display: 'flex', - flexDirection: 'column', - alignItems: 'stretch', - }, -}; - -type Props = {| - hideTitle?: boolean, - margin?: 'dense', -|}; - -const UserEarnings = ({ hideTitle, margin }: Props) => { - const { userEarningsBalance, onRefreshEarningsBalance } = React.useContext( - AuthenticatedUserContext - ); - const theme = React.useContext(GDevelopThemeContext); - - const [earningsInMilliUsd, setEarningsInMilliUsd] = React.useState(0); - const [earningsInCredits, setEarningsInCredits] = React.useState(0); - const [error, setError] = React.useState(null); - const intervalValuesUpdate = React.useRef(null); - - const [selectedCashOutType, setSelectedCashOutType] = React.useState< - ?'cash' | 'credits' - >(null); - - const fetchUserEarningsBalance = React.useCallback( - async () => { - if (!userEarningsBalance) return; - - try { - // Create an animation to show the earnings increasing. - const targetMilliUsd = userEarningsBalance.amountInMilliUSDs; - const targetCredits = userEarningsBalance.amountInCredits; - - const duration = 500; - const steps = 30; - const intervalTime = duration / steps; - - const milliUsdIncrement = (targetMilliUsd - earningsInMilliUsd) / steps; - const creditsIncrement = (targetCredits - earningsInCredits) / steps; - - let currentMilliUsd = earningsInMilliUsd; - let currentCredits = earningsInCredits; - let step = 0; - - intervalValuesUpdate.current = setInterval(() => { - step++; - currentMilliUsd += milliUsdIncrement; - currentCredits += creditsIncrement; - - setEarningsInMilliUsd(currentMilliUsd); - setEarningsInCredits(currentCredits); - - if (step >= steps) { - clearInterval(intervalValuesUpdate.current); - // Ensure final values are exactly the target values - setEarningsInMilliUsd(targetMilliUsd); - setEarningsInCredits(targetCredits); - } - }, intervalTime); - } catch (error) { - console.error('Unable to get user earnings balance:', error); - setError(error); - } - }, - [userEarningsBalance, earningsInMilliUsd, earningsInCredits] - ); - - React.useEffect( - () => { - fetchUserEarningsBalance(); - }, - // Fetch the earnings once on mount. - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - React.useEffect( - () => () => { - // Cleanup the interval when the component is unmounted. - if (intervalValuesUpdate.current) { - clearInterval(intervalValuesUpdate.current); - } - }, - [] - ); - - const canCashout = - userEarningsBalance && - earningsInMilliUsd >= userEarningsBalance.minAmountToCashoutInMilliUSDs; - - const content = ( - - {!hideTitle && ( - - - - Total earnings - - - - Window.openExternalURL( - 'https://wiki.gdevelop.io/gdevelop5/monetization/' - ) - } - > - Learn about revenue on gd.games - - - - - )} - {error && ( - - - - Can't load the total earnings. Verify your internet connection or - try again later. - - - - )} - {!error && ( - - - - - {(earningsInMilliUsd / 1000).toFixed(2)} - - USD - - - - Collect at least{' '} - {userEarningsBalance.minAmountToCashoutInMilliUSDs / 1000}{' '} - USD to cash out your earnings - - ) : ( - '' - ) - } - > - {/* Button must be wrapped in a container so that the parent tooltip - can display even if the button is disabled. */} -
- } - disabled={!canCashout} - primary - label={Cash out} - onClick={() => { - setSelectedCashOutType('cash'); - }} - /> -
-
-
- -
- - - - - {earningsInCredits.toFixed(0)} - - Credits - - - - } - primary - disabled={earningsInCredits === 0} - label={Credit out} - onClick={() => { - setSelectedCashOutType('credits'); - }} - /> - - - )} - - ); - - return ( - <> - - - {margin === 'dense' ? ( - content - ) : ( - {content} - )} - - - {selectedCashOutType && userEarningsBalance && ( - setSelectedCashOutType(null)} - onSuccess={onRefreshEarningsBalance} - type={selectedCashOutType} - /> - )} - - ); -}; - -export default UserEarnings; diff --git a/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js b/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js new file mode 100644 index 000000000000..8ee508a900cd --- /dev/null +++ b/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js @@ -0,0 +1,250 @@ +// @flow +import { Trans } from '@lingui/macro'; +import * as React from 'react'; + +import { LineStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout'; +import Text from '../../UI/Text'; +import { Spacer } from '../../UI/Grid'; +import BackgroundText from '../../UI/BackgroundText'; +import Link from '../../UI/Link'; +import Window from '../../Utils/Window'; +import RaisedButton from '../../UI/RaisedButton'; +import Coin from '../../Credits/Icons/Coin'; +import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; +import PlaceholderError from '../../UI/PlaceholderError'; +import Tooltip from '@material-ui/core/Tooltip'; +import CreditOutDialog from './CashOutDialog'; +import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; +import DashboardWidget, { + type DashboardWidgetSize, +} from '../Widgets/DashboardWidget'; +import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; +import { getHelpLink } from '../../Utils/HelpLink'; + +const monetizationHelpLink = getHelpLink('/monetization'); + +const styles = { + separator: { + height: 50, + }, + buttonContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + }, +}; + +type Props = {| + size: DashboardWidgetSize, +|}; + +const UserEarningsWidget = ({ size }: Props) => { + const { + userEarningsBalance, + onRefreshEarningsBalance, + onRefreshLimits, + } = React.useContext(AuthenticatedUserContext); + const theme = React.useContext(GDevelopThemeContext); + const { isMobile } = useResponsiveWindowSize(); + + const [earningsInMilliUsd, setEarningsInMilliUsd] = React.useState(0); + const [earningsInCredits, setEarningsInCredits] = React.useState(0); + const [error, setError] = React.useState(null); + const intervalValuesUpdate = React.useRef(null); + + const [selectedCashOutType, setSelectedCashOutType] = React.useState< + ?'cash' | 'credits' + >(null); + + const animateEarnings = React.useCallback( + async () => { + if (!userEarningsBalance) return; + + try { + // Create an animation to show the earnings increasing. + const targetMilliUsd = userEarningsBalance.amountInMilliUSDs; + const targetCredits = userEarningsBalance.amountInCredits; + + const duration = 500; + const steps = 30; + const intervalTime = duration / steps; + + const milliUsdIncrement = targetMilliUsd / steps; + const creditsIncrement = targetCredits / steps; + + let currentMilliUsd = 0; + let currentCredits = 0; + let step = 0; + + intervalValuesUpdate.current = setInterval(() => { + step++; + currentMilliUsd += milliUsdIncrement; + currentCredits += creditsIncrement; + + setEarningsInMilliUsd(currentMilliUsd); + setEarningsInCredits(currentCredits); + + if (step >= steps) { + clearInterval(intervalValuesUpdate.current); + // Ensure final values are exactly the target values + setEarningsInMilliUsd(targetMilliUsd); + setEarningsInCredits(targetCredits); + } + }, intervalTime); + } catch (error) { + console.error('Unable to get user earnings balance:', error); + setError(error); + } + }, + [userEarningsBalance] + ); + + React.useEffect( + () => { + animateEarnings(); + }, + [animateEarnings] + ); + + React.useEffect( + () => () => { + // Cleanup the interval when the component is unmounted. + if (intervalValuesUpdate.current) { + clearInterval(intervalValuesUpdate.current); + } + }, + [] + ); + + const onCashOrCreditOut = React.useCallback( + async () => { + await Promise.all([onRefreshEarningsBalance(), onRefreshLimits()]); + }, + [onRefreshEarningsBalance, onRefreshLimits] + ); + + const canCashout = + userEarningsBalance && + earningsInMilliUsd >= userEarningsBalance.minAmountToCashoutInMilliUSDs; + + const content = error ? ( + + + + Can't load your game earnings. Verify your internet connection or try + again later. + + + + ) : ( + + + Window.openExternalURL(monetizationHelpLink)} + > + Learn about revenue on gd.games + + + + + + USD + + {(earningsInMilliUsd / 1000).toFixed(2)} + + + + + Collect at least{' '} + {userEarningsBalance.minAmountToCashoutInMilliUSDs / 1000} USD + to cash out your earnings + + ) : ( + '' + ) + } + > + {/* Button must be wrapped in a container so that the parent tooltip + can display even if the button is disabled. */} +
+ Cash out} + onClick={() => { + setSelectedCashOutType('cash'); + }} + /> +
+
+
+ +
+ + + + + + {earningsInCredits.toFixed(0)} + + + + Credit out} + onClick={() => { + setSelectedCashOutType('credits'); + }} + /> + + + + ); + + return ( + <> + Game earnings} + widgetName="earnings" + > + {content} + + {selectedCashOutType && userEarningsBalance && ( + setSelectedCashOutType(null)} + onSuccess={onCashOrCreditOut} + type={selectedCashOutType} + /> + )} + + ); +}; + +export default UserEarningsWidget; diff --git a/newIDE/app/src/GameDashboard/PublicGamePropertiesDialog.js b/newIDE/app/src/GameDashboard/PublicGamePropertiesDialog.js index 627fb110198a..accf5521df30 100644 --- a/newIDE/app/src/GameDashboard/PublicGamePropertiesDialog.js +++ b/newIDE/app/src/GameDashboard/PublicGamePropertiesDialog.js @@ -18,8 +18,7 @@ import { } from '../Utils/GDevelopServices/Game'; import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; import CircledClose from '../UI/CustomSvgIcons/CircledClose'; -import { Column, Line } from '../UI/Grid'; -import AlertMessage from '../UI/AlertMessage'; +import { Line } from '../UI/Grid'; import LeftLoader from '../UI/LeftLoader'; type PublicProjectProperties = {| @@ -91,7 +90,6 @@ type Props = {| onGameUpdated?: (game: Game) => void, canBePublishedOnGdGames: boolean, onUnregisterGame: () => Promise, - gameUnregisterErrorText: ?React.Node, |}; export const PublicGamePropertiesDialog = ({ @@ -104,7 +102,6 @@ export const PublicGamePropertiesDialog = ({ onGameUpdated, canBePublishedOnGdGames, onUnregisterGame, - gameUnregisterErrorText, }: Props) => { const { profile } = React.useContext(AuthenticatedUserContext); @@ -247,17 +244,13 @@ export const PublicGamePropertiesDialog = ({ disabled={isLoading} canBePublishedOnGdGames={canBePublishedOnGdGames} /> - {gameUnregisterErrorText && ( - - {gameUnregisterErrorText} - - )} Unregister game} leftIcon={} + disabled={isLoading} /> diff --git a/newIDE/app/src/GameDashboard/UseGamesList.js b/newIDE/app/src/GameDashboard/UseGamesList.js index 2691aea1508a..771c7d5712b2 100644 --- a/newIDE/app/src/GameDashboard/UseGamesList.js +++ b/newIDE/app/src/GameDashboard/UseGamesList.js @@ -1,7 +1,11 @@ // @flow import * as React from 'react'; -import { getGames, type Game } from '../Utils/GDevelopServices/Game'; +import { + getGames, + updateGame, + type Game, +} from '../Utils/GDevelopServices/Game'; import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; export type GamesList = {| @@ -9,6 +13,7 @@ export type GamesList = {| gamesFetchingError: ?Error, fetchGames: () => Promise, onGameUpdated: (updatedGame: Game) => void, + markGameAsSavedIfRelevant: (gameId: string) => Promise, |}; const useGamesList = (): GamesList => { @@ -28,7 +33,7 @@ const useGamesList = (): GamesList => { const fetchGames = React.useCallback( async (): Promise => { if (!authenticated || !firebaseUser) { - setGames(null); + setGames([]); return; } if (gamesFetchingPromise.current) return gamesFetchingPromise.current; @@ -61,11 +66,38 @@ const useGamesList = (): GamesList => { [games] ); + const markGameAsSavedIfRelevant = React.useCallback( + async (gameId: string) => { + if (!games || !firebaseUser) return; + const currentOpenedGame = games && games.find(game => game.id === gameId); + + if (!currentOpenedGame || currentOpenedGame.savedStatus !== 'draft') + return; + + try { + const updatedGame = await updateGame( + getAuthorizationHeader, + firebaseUser.uid, + currentOpenedGame.id, + { + savedStatus: 'saved', + } + ); + onGameUpdated(updatedGame); + } catch (error) { + // Catch error, we'll try again later. + console.error('Error while marking game as saved:', error); + } + }, + [games, onGameUpdated, firebaseUser, getAuthorizationHeader] + ); + return { games, gamesFetchingError, fetchGames, onGameUpdated, + markGameAsSavedIfRelevant, }; }; diff --git a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js new file mode 100644 index 000000000000..98f6d039d826 --- /dev/null +++ b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js @@ -0,0 +1,62 @@ +// @flow + +import * as React from 'react'; +import DashboardWidget, { + type DashboardWidgetSize, +} from '../Widgets/DashboardWidget'; +import { ColumnStackLayout } from '../../UI/Layout'; +import Coin from '../../Credits/Icons/Coin'; +import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; +import { EarnCredits } from '../../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits'; +import TextButton from '../../UI/TextButton'; +import { Trans } from '@lingui/macro'; + +type Props = {| + onOpenProfile: () => void, + size: DashboardWidgetSize, + showOneItem?: boolean, + showAllItems?: boolean, +|}; + +const WalletWidget = ({ + onOpenProfile, + size, + showOneItem, + showAllItems, +}: Props) => { + const { + profile, + limits, + achievements, + badges, + onOpenCreateAccountDialog, + } = React.useContext(AuthenticatedUserContext); + const creditsAvailable = limits ? limits.credits.userBalance.amount : 0; + + return ( + Wallet} + topRightAction={ + } + label={creditsAvailable.toString()} + onClick={profile ? onOpenProfile : onOpenCreateAccountDialog} + /> + } + widgetName="wallet" + > + + + + + ); +}; + +export default WalletWidget; diff --git a/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js b/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js index 6c6bddd05bca..2fad54fbf437 100644 --- a/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js @@ -22,10 +22,7 @@ import { getHelpLink } from '../../Utils/HelpLink'; import Window from '../../Utils/Window'; import Link from '../../UI/Link'; -const publishingHelpLink = getHelpLink( - 'gdevelop5/publishing', - 'publish-your-game' -); +const publishingHelpLink = getHelpLink('/publishing', 'publish-your-game'); const styles = { loadingSpace: { height: 100 } }; @@ -39,23 +36,34 @@ type Props = {| const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => { const hasNoSession = gameMetrics && gameMetrics.length === 0; const { isMobile } = useResponsiveWindowSize(); - const chartData = React.useMemo(() => buildLastWeekChartData(gameMetrics), [ - gameMetrics, - ]); + const oneWeekAgoIsoDate = new Date( + new Date().setHours(0, 0, 0, 0) - 7 * 24 * 3600 * 1000 + ).toISOString(); const [ marketingPlansDialogOpen, setMarketingPlansDialogOpen, ] = React.useState(false); + const chartData = React.useMemo( + () => { + const lastWeekGameMetrics = gameMetrics + ? gameMetrics.filter(metrics => metrics.date > oneWeekAgoIsoDate) + : null; + + return buildLastWeekChartData(lastWeekGameMetrics); + }, + [gameMetrics, oneWeekAgoIsoDate] + ); + return ( <> {({ i18n }) => ( Analytics} - seeMoreButton={ + topRightAction={ See all} rightIcon={} @@ -63,6 +71,7 @@ const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => { primary /> } + widgetName="analytics" > {!gameMetrics ? ( @@ -84,7 +93,12 @@ const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => { ) : ( - + diff --git a/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js b/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js index 43009c469fe8..4c74a9185b5b 100644 --- a/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js @@ -32,9 +32,9 @@ const BuildsWidget = ({ builds, onSeeAllBuilds }: Props) => { return ( Exports} - seeMoreButton={ + topRightAction={ See all} rightIcon={} @@ -42,6 +42,7 @@ const BuildsWidget = ({ builds, onSeeAllBuilds }: Props) => { primary /> } + widgetName="builds" > {pendingBuilds && pendingBuilds.length > 0 && ( diff --git a/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js b/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js index 24ee80a8f8c1..53f8102b7dcc 100644 --- a/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js @@ -6,71 +6,101 @@ import Paper from '../../UI/Paper'; import Text from '../../UI/Text'; import { Column, Line } from '../../UI/Grid'; import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; +import { ColumnStackLayout } from '../../UI/Layout'; +import { dataObjectToProps } from '../../Utils/HTMLDataset'; -const verticalPadding = 8; +const padding = 16; const fixedHeight = 300; const styles = { paper: { - padding: `${verticalPadding}px 12px`, + padding: `${padding}px`, display: 'flex', flexDirection: 'column', alignItems: 'stretch', }, - content: { - display: 'flex', - flexDirection: 'column', - alignItems: 'stretch', - minHeight: 0, - flex: 1, - }, maxHeightNotWrapped: { - minHeight: fixedHeight, - height: `calc(100% - ${2 * verticalPadding}px)`, + height: `calc(100% - ${2 * padding}px)`, }, }; +export type DashboardWidgetSize = 'full' | 'half' | 'oneThird' | 'twoThirds'; + +const getGridSizeFromWidgetSize = (size: DashboardWidgetSize) => { + switch (size) { + case 'full': + return 12; + case 'half': + return 6; + case 'oneThird': + return 4; + case 'twoThirds': + return 8; + default: + return 12; + } +}; + +type GameDashboardWidgetName = + | 'analytics' + | 'feedback' + | 'services' + | 'projects' + | 'builds' + | 'wallet' + | 'earnings'; + type Props = {| title: React.Node, - seeMoreButton?: React.Node, + topRightAction?: React.Node, renderSubtitle?: ?() => React.Node, - gridSize: number, + widgetSize: DashboardWidgetSize, children?: React.Node, - withMinHeight?: boolean, + minHeight?: boolean, + widgetName: GameDashboardWidgetName, |}; const DashboardWidget = ({ title, - seeMoreButton, - gridSize, + topRightAction, + widgetSize, renderSubtitle, children, - withMinHeight, + minHeight, + widgetName, }: Props) => { const { isMobile } = useResponsiveWindowSize(); return ( - + -
- + + - + {title} {renderSubtitle && renderSubtitle()} - {seeMoreButton} + {topRightAction} {children} -
+
); diff --git a/newIDE/app/src/GameDashboard/Widgets/FeedbackWidget.js b/newIDE/app/src/GameDashboard/Widgets/FeedbackWidget.js index c674b66236bf..5b931b52a186 100644 --- a/newIDE/app/src/GameDashboard/Widgets/FeedbackWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/FeedbackWidget.js @@ -48,9 +48,9 @@ const FeedbackWidget = ({ {({ i18n }) => ( Feedbacks} - seeMoreButton={ + topRightAction={ !feedbacks || feedbacks.length === 0 ? null : ( See more} @@ -60,7 +60,8 @@ const FeedbackWidget = ({ /> ) } - withMinHeight + widgetName="feedback" + minHeight renderSubtitle={() => shouldDisplayControlToCollectFeedback ? null : unprocessedFeedbacks && feedbacks ? ( @@ -151,7 +152,7 @@ const FeedbackWidget = ({
) : gameUrl ? ( - + @@ -162,7 +163,12 @@ const FeedbackWidget = ({ ) : ( - + diff --git a/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js b/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js index c5bcdf781b64..b1463521f180 100644 --- a/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js @@ -1,6 +1,6 @@ // @flow import * as React from 'react'; -import { I18n } from '@lingui/react'; +import { I18n as I18nType } from '@lingui/core'; import { Trans } from '@lingui/macro'; import { type Game } from '../../Utils/GDevelopServices/Game'; import { @@ -15,6 +15,11 @@ type Props = {| game: Game, onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, storageProviders: Array, + onDeleteCloudProject: ( + i18n: I18nType, + file: FileMetadataAndStorageProviderName + ) => Promise, + disabled: boolean, project: ?gdProject, currentFileMetadata: ?FileMetadata, @@ -23,13 +28,13 @@ type Props = {| const ProjectsWidget = (props: Props) => { return ( - - {({ i18n }) => ( - Projects}> - -
- )} -
+ Projects} + widgetName="projects" + > + + ); }; diff --git a/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js b/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js index 139d68423a04..7b161d7c10d6 100644 --- a/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js @@ -38,11 +38,15 @@ const ServicesWidget = ({ SubscriptionSuggestionContext ); return ( - Player services}> + Player services} + widgetName="services" + > - + Game leaderboards - + Multiplayer lobbies Promise, storageProviders: Array, closeProject: () => Promise, + onDeleteCloudProject: ( + i18n: I18nType, + file: FileMetadataAndStorageProviderName + ) => Promise, // Current game: game: Game, onGameUpdated: (game: Game) => void, onUnregisterGame: (i18n: I18nType) => Promise, - gameUnregisterErrorText: ?React.Node, // Navigation: currentView: GameDetailsTab, setCurrentView: GameDetailsTab => void, onBack: () => void, + disabled: boolean, + initialWidgetToScrollTo?: ?string, |}; const GameDashboard = ({ @@ -93,18 +110,25 @@ const GameDashboard = ({ onOpenProject, storageProviders, closeProject, + onDeleteCloudProject, // Current game: game, onGameUpdated, onUnregisterGame, - gameUnregisterErrorText, // Navigation: currentView, setCurrentView, onBack, + disabled, + initialWidgetToScrollTo, }: Props) => { + const grid = React.useRef(null); + const { isMobile } = useResponsiveWindowSize(); + const [widgetToScrollTo, setWidgetToScrollTo] = React.useState( + initialWidgetToScrollTo + ); const [ gameDetailsDialogOpen, setGameDetailsDialogOpen, @@ -130,9 +154,9 @@ const GameDashboard = ({ const [leaderboards, setLeaderboards] = React.useState>( null ); - const oneWeekAgo = React.useRef( - new Date(new Date().setHours(0, 0, 0, 0) - 7 * 24 * 3600 * 1000) - ); + const lastYearIsoDate = formatISO(subDays(new Date(), daysShownForYear), { + representation: 'date', + }); const webBuilds = builds ? builds.filter(build => build.type === 'web-build') @@ -411,6 +435,21 @@ const GameDashboard = ({ React.useEffect( () => { + if (widgetToScrollTo && grid.current) { + const widget = grid.current.querySelector( + `[data-widget-name="${widgetToScrollTo}"]` + ); + if (widget) { + widget.scrollIntoView({ behavior: 'smooth', inline: 'start' }); + setWidgetToScrollTo(null); + } + } + }, + [initialWidgetToScrollTo, widgetToScrollTo] + ); + + const fetchAuthenticatedData = React.useCallback( + async () => { if (!profile) { setFeedbacks(null); setBuilds(null); @@ -421,46 +460,51 @@ const GameDashboard = ({ return; } - const fetchAuthenticatedData = async () => { - const [ - feedbacks, - builds, - gameRollingMetrics, - leaderboards, - recommendedMarketingPlan, - ] = await Promise.all([ - listComments(getAuthorizationHeader, profile.id, { - gameId: game.id, - type: 'FEEDBACK', - }), - getBuilds(getAuthorizationHeader, profile.id, game.id), - getGameMetricsFrom( - getAuthorizationHeader, - profile.id, - game.id, - oneWeekAgo.current.toISOString() - ), - listGameActiveLeaderboards( - getAuthorizationHeader, - profile.id, - game.id - ), - getRecommendedMarketingPlan(getAuthorizationHeader, { - gameId: game.id, - userId: profile.id, - }), - fetchGameFeaturings(), - ]); - setFeedbacks(feedbacks); - setBuilds(builds); - setGameMetrics(gameRollingMetrics); - setLeaderboards(leaderboards); - setRecommendedMarketingPlan(recommendedMarketingPlan); - }; + const [ + feedbacks, + builds, + gameRollingMetrics, + leaderboards, + recommendedMarketingPlan, + ] = await Promise.all([ + listComments(getAuthorizationHeader, profile.id, { + gameId: game.id, + type: 'FEEDBACK', + }), + getBuilds(getAuthorizationHeader, profile.id, game.id), + getGameMetricsFrom( + getAuthorizationHeader, + profile.id, + game.id, + lastYearIsoDate + ), + listGameActiveLeaderboards(getAuthorizationHeader, profile.id, game.id), + getRecommendedMarketingPlan(getAuthorizationHeader, { + gameId: game.id, + userId: profile.id, + }), + fetchGameFeaturings(), + ]); + setFeedbacks(feedbacks); + setBuilds(builds); + setGameMetrics(gameRollingMetrics); + setLeaderboards(leaderboards); + setRecommendedMarketingPlan(recommendedMarketingPlan); + }, + [ + fetchGameFeaturings, + game.id, + getAuthorizationHeader, + profile, + lastYearIsoDate, + ] + ); + React.useEffect( + () => { fetchAuthenticatedData(); }, - [getAuthorizationHeader, profile, fetchGameFeaturings, game.id] + [fetchAuthenticatedData] ); const onClickBack = React.useCallback( @@ -468,10 +512,12 @@ const GameDashboard = ({ if (currentView === 'details') { onBack(); } else { + // Refresh the data when going back to the main view. + fetchAuthenticatedData(); setCurrentView('details'); } }, - [currentView, onBack, setCurrentView] + [currentView, onBack, setCurrentView, fetchAuthenticatedData] ); return ( @@ -533,7 +579,7 @@ const GameDashboard = ({ : null } /> - + setCurrentView('analytics')} gameMetrics={gameRollingMetrics} @@ -564,12 +610,17 @@ const GameDashboard = ({ onOpenProject={onOpenProject} storageProviders={storageProviders} closeProject={closeProject} + onDeleteCloudProject={onDeleteCloudProject} + disabled={disabled} /> setCurrentView('builds')} /> +
)} @@ -578,7 +629,7 @@ const GameDashboard = ({ i18n={i18n} onClose={() => setGameDetailsDialogOpen(false)} publicGame={publicGame} - isLoading={isUpdatingGame} + isLoading={isUpdatingGame || disabled} onApply={async properties => { const updatedGame = await onUpdateGame(i18n, properties); if (updatedGame) { @@ -590,7 +641,6 @@ const GameDashboard = ({ onGameUpdated={_onGameUpdated} onUpdatingGame={setIsUpdatingGame} onUnregisterGame={() => onUnregisterGame(i18n)} - gameUnregisterErrorText={gameUnregisterErrorText} /> )} diff --git a/newIDE/app/src/InstancesEditor/InstancesList/index.js b/newIDE/app/src/InstancesEditor/InstancesList/index.js index 1111072aa6f8..e91a534357d5 100644 --- a/newIDE/app/src/InstancesEditor/InstancesList/index.js +++ b/newIDE/app/src/InstancesEditor/InstancesList/index.js @@ -124,7 +124,7 @@ class InstancesList extends Component { if (this.instanceRowRenderer) this.instanceRowRenderer.delete(); } - _onRowClick = ({ index }: { index: number }) => { + _onRowClick = ({ index }: {| index: number |}) => { if (!this.renderedRows[index]) return; this.props.onSelectInstances( @@ -133,11 +133,11 @@ class InstancesList extends Component { ); }; - _rowGetter = ({ index }: { index: number }) => { + _rowGetter = ({ index }: {| index: number |}) => { return this.renderedRows[index]; }; - _rowClassName = ({ index }: { index: number }) => { + _rowClassName = ({ index }: {| index: number |}) => { if (index < 0) { return 'tableHeaderRow'; } else { diff --git a/newIDE/app/src/InstancesEditor/index.js b/newIDE/app/src/InstancesEditor/index.js index b6351e454ee3..2a47c7bc6060 100644 --- a/newIDE/app/src/InstancesEditor/index.js +++ b/newIDE/app/src/InstancesEditor/index.js @@ -1426,7 +1426,7 @@ export default class InstancesEditor extends Component { fitViewToRectangle( rectangle: Rectangle, - { adaptZoom }: { adaptZoom: boolean } + { adaptZoom }: {| adaptZoom: boolean |} ) { const idealZoom = this.viewPosition.fitToRectangle(rectangle); if (adaptZoom) this.setZoomFactor(idealZoom); diff --git a/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js b/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js index 54f95fb3f394..50fa5f4a5881 100644 --- a/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js +++ b/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js @@ -21,7 +21,7 @@ import AuthenticatedUserContext, { import { duplicateLeaderboard } from '../Utils/GDevelopServices/Play'; import { registerGame } from '../Utils/GDevelopServices/Game'; import { toNewGdMapStringString } from '../Utils/MapStringString'; -import { getDefaultRegisterGamePropertiesFromProject } from '../Utils/UseGameAndBuildsManager'; +import { getDefaultRegisterGameProperties } from '../Utils/UseGameAndBuildsManager'; const gd: libGDevelop = global.gd; @@ -240,7 +240,13 @@ export const replaceLeaderboardsInProject = async ({ await registerGame( getAuthorizationHeader, profile.id, - getDefaultRegisterGamePropertiesFromProject({ project }) + getDefaultRegisterGameProperties({ + projectId: project.getProjectUuid(), + projectName: project.getName(), + projectAuthor: project.getAuthor(), + // Assume the project is not saved at this stage. + savedStatus: 'draft', + }) ); } catch (error) { console.error( diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js deleted file mode 100644 index bec74bcfff78..000000000000 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js +++ /dev/null @@ -1,389 +0,0 @@ -// @flow -import * as React from 'react'; -import { type I18n as I18nType } from '@lingui/core'; -import { Trans, t } from '@lingui/macro'; - -import Text from '../../../../UI/Text'; -import TextButton from '../../../../UI/TextButton'; -import RaisedButton from '../../../../UI/RaisedButton'; -import { Line, Column, Spacer } from '../../../../UI/Grid'; -import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer'; -import { - LineStackLayout, - ResponsiveLineStackLayout, -} from '../../../../UI/Layout'; -import Carousel from '../../../../UI/Carousel'; -import { - type FileMetadataAndStorageProviderName, - type FileMetadata, - type StorageProvider, -} from '../../../../ProjectsStorage'; -import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext'; -import SectionContainer, { SectionRow } from '../SectionContainer'; -import { - checkIfHasTooManyCloudProjects, - MaxProjectCountAlertMessage, -} from '../CreateSection/MaxProjectCountAlertMessage'; -import { ExampleStoreContext } from '../../../../AssetStore/ExampleStore/ExampleStoreContext'; -import { SubscriptionSuggestionContext } from '../../../../Profile/Subscription/SubscriptionSuggestionContext'; -import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example'; -import Add from '../../../../UI/CustomSvgIcons/Add'; -import PlaceholderError from '../../../../UI/PlaceholderError'; -import AlertMessage from '../../../../UI/AlertMessage'; -import IconButton from '../../../../UI/IconButton'; -import { type PrivateGameTemplateListingData } from '../../../../Utils/GDevelopServices/Shop'; -import { PrivateGameTemplateStoreContext } from '../../../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext'; -import ChevronArrowRight from '../../../../UI/CustomSvgIcons/ChevronArrowRight'; -import Refresh from '../../../../UI/CustomSvgIcons/Refresh'; -import { getExampleAndTemplateTiles } from '../CreateSection/utils'; -import ErrorBoundary from '../../../../UI/ErrorBoundary'; -import InfoBar from '../../../../UI/Messages/InfoBar'; -import GridList from '@material-ui/core/GridList'; -import type { WindowSizeType } from '../../../../UI/Responsive/ResponsiveWindowMeasurer'; -import PromotionsSlideshow from '../../../../Promotions/PromotionsSlideshow'; -import ExampleStore from '../../../../AssetStore/ExampleStore'; -import ProjectFileList from '../CreateSection/ProjectFileList'; - -const cellSpacing = 2; - -const getItemsColumns = (windowSize: WindowSizeType, isLandscape: boolean) => { - switch (windowSize) { - case 'small': - return isLandscape ? 4 : 2; - case 'medium': - return 3; - case 'large': - return 4; - case 'xlarge': - return 5; - default: - return 3; - } -}; - -const styles = { - listItem: { - padding: 0, - marginTop: 2, - marginBottom: 2, - borderRadius: 8, - overflowWrap: 'anywhere', // Ensure everything is wrapped on small devices. - }, - 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 = {| - project: ?gdProject, - currentFileMetadata: ?FileMetadata, - canOpen: boolean, - onChooseProject: () => void, - onOpenRecentFile: (file: FileMetadataAndStorageProviderName) => Promise, - onOpenNewProjectSetupDialog: () => void, - onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void, - onSelectPrivateGameTemplateListingData: ( - privateGameTemplateListingData: PrivateGameTemplateListingData - ) => void, - storageProviders: Array, - i18n: I18nType, - onManageGame: (gameId: string) => void, - canManageGame: (gameId: string) => boolean, - closeProject: () => Promise, -|}; - -const BuildSection = ({ - project, - currentFileMetadata, - canOpen, - onChooseProject, - onOpenNewProjectSetupDialog, - onSelectExampleShortHeader, - onSelectPrivateGameTemplateListingData, - onOpenRecentFile, - storageProviders, - i18n, - onManageGame, - canManageGame, - closeProject, -}: Props) => { - const { exampleShortHeaders } = React.useContext(ExampleStoreContext); - const [ - showAllGameTemplates, - setShowAllGameTemplates, - ] = React.useState(false); - const { privateGameTemplateListingDatas } = React.useContext( - PrivateGameTemplateStoreContext - ); - const authenticatedUser = React.useContext(AuthenticatedUserContext); - const { openSubscriptionDialog } = React.useContext( - SubscriptionSuggestionContext - ); - const [isRefreshing, setIsRefreshing] = React.useState(false); - const [ - showCloudProjectsInfoIfNotLoggedIn, - setShowCloudProjectsInfoIfNotLoggedIn, - ] = React.useState(false); - const { - authenticated, - limits, - cloudProjectsFetchingErrorLabel, - onCloudProjectsChanged, - onOpenLoginDialog, - } = authenticatedUser; - const { windowSize, isMobile, isLandscape } = useResponsiveWindowSize(); - - const columnsCount = getItemsColumns(windowSize, isLandscape); - - const hasTooManyCloudProjects = checkIfHasTooManyCloudProjects( - authenticatedUser - ); - - const refreshCloudProjects = React.useCallback( - async () => { - if (isRefreshing) return; - if (!authenticated) { - setShowCloudProjectsInfoIfNotLoggedIn(true); - return; - } - try { - setIsRefreshing(true); - await onCloudProjectsChanged(); - } finally { - // Wait a bit to avoid spam as we don't have a "loading" state. - setTimeout(() => setIsRefreshing(false), 2000); - } - }, - [onCloudProjectsChanged, isRefreshing, authenticated] - ); - - const shouldDisplayPremiumGameTemplates = - !authenticatedUser || - !authenticatedUser.limits || - !authenticatedUser.limits.capabilities.classrooms || - !authenticatedUser.limits.capabilities.classrooms.hidePremiumProducts; - - const examplesAndTemplatesToDisplay = React.useMemo( - () => - getExampleAndTemplateTiles({ - receivedGameTemplates: authenticatedUser.receivedGameTemplates, - privateGameTemplateListingDatas, - exampleShortHeaders, - onSelectPrivateGameTemplateListingData, - onSelectExampleShortHeader, - i18n, - numberOfItemsExclusivelyInCarousel: isMobile ? 3 : 5, - numberOfItemsInCarousel: isMobile ? 8 : 12, - privateGameTemplatesPeriodicity: shouldDisplayPremiumGameTemplates - ? 2 - : 0, - }), - [ - authenticatedUser.receivedGameTemplates, - exampleShortHeaders, - onSelectExampleShortHeader, - onSelectPrivateGameTemplateListingData, - privateGameTemplateListingDatas, - i18n, - isMobile, - shouldDisplayPremiumGameTemplates, - ] - ); - - const shouldDisplayAnnouncements = - !authenticatedUser.limits || - !authenticatedUser.limits.capabilities.classrooms || - !authenticatedUser.limits.capabilities.classrooms.hidePlayTab; - - const pageContent = showAllGameTemplates ? ( - setShowAllGameTemplates(false)} - flexBody - > - - - - - ) : ( - ( - - - - openSubscriptionDialog({ - analyticsMetadata: { - reason: 'Cloud Project limit reached', - }, - }) - } - /> - - - ) - : undefined - } - > - - Ready-made games} - displayItemTitles={false} - browseAllLabel={Browse all templates} - onBrowseAllClick={() => setShowAllGameTemplates(true)} - items={examplesAndTemplatesToDisplay.carouselThumbnailItems} - browseAllIcon={} - roundedImages - displayArrowsOnDesktop - /> - {shouldDisplayAnnouncements && ( - <> - - - - - - )} - - - - - - - My projects - - -
- -
-
-
-
- - - Create - ) : ( - Create new game - ) - } - onClick={onOpenNewProjectSetupDialog} - icon={} - id="home-create-project-button" - /> - {canOpen && ( - <> - - or - - - Open - ) : ( - Open a project - ) - } - onClick={onChooseProject} - /> - - )} - - -
- {cloudProjectsFetchingErrorLabel && ( - - - - {cloudProjectsFetchingErrorLabel} - - - - )} - -
- - - - - Start with a template - - - - - {examplesAndTemplatesToDisplay.gridItemsCompletingCarousel} - - -
- ); - - return ( - <> - {pageContent} - Log in to see your cloud projects.} - visible={showCloudProjectsInfoIfNotLoggedIn} - hide={() => setShowCloudProjectsInfoIfNotLoggedIn(false)} - duration={5000} - onActionClick={onOpenLoginDialog} - actionLabel={Log in} - /> - - ); -}; - -const BuildSectionWithErrorBoundary = (props: Props) => ( - Build section} - scope="start-page-build" - > - - -); - -export default BuildSectionWithErrorBoundary; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/AvatarWithStatusAndTooltip.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/AvatarWithStatusAndTooltip.js new file mode 100644 index 000000000000..ec0f69041cbb --- /dev/null +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/AvatarWithStatusAndTooltip.js @@ -0,0 +1,44 @@ +// @flow +import * as React from 'react'; +import Avatar from '@material-ui/core/Avatar'; +import Tooltip from '@material-ui/core/Tooltip'; +import DotBadge from '../../../../UI/DotBadge'; +import StatusIndicator from './StatusIndicator'; + +const styles = { + avatar: { + width: 20, + height: 20, + }, +}; + +type AvatarWithStatusAndTooltipProps = {| + avatarUrl: ?string, + status: 'success' | 'error', + tooltipMessage: ?string, + hideStatus?: boolean, +|}; + +const AvatarWithStatusAndTooltip = ({ + avatarUrl, + status, + tooltipMessage, + hideStatus, +}: AvatarWithStatusAndTooltipProps) => + !!avatarUrl ? ( + tooltipMessage ? ( + + + + + + ) : ( + + + + ) + ) : ( + + ); + +export default AvatarWithStatusAndTooltip; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo.js new file mode 100644 index 000000000000..4cabbcb80daf --- /dev/null +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo.js @@ -0,0 +1,120 @@ +// @flow +import * as React from 'react'; +import { Trans } from '@lingui/macro'; +import { I18n } from '@lingui/react'; +import Text from '../../../../UI/Text'; +import { LineStackLayout } from '../../../../UI/Layout'; +import { + type FileMetadataAndStorageProviderName, + type StorageProvider, +} from '../../../../ProjectsStorage'; +import { type AuthenticatedUser } from '../../../../Profile/AuthenticatedUserContext'; +import { getRelativeOrAbsoluteDisplayDate } from '../../../../Utils/DateDisplay'; +import { getGravatarUrl } from '../../../../UI/GravatarUrl'; +import { type LastModifiedInfo } from './utils'; +import { type FileMetadata } from '../../../../ProjectsStorage'; +import AvatarWithStatusAndTooltip from './AvatarWithStatusAndTooltip'; + +type LastModificationInfoProps = {| + file: FileMetadataAndStorageProviderName, + lastModifiedInfo?: LastModifiedInfo | null, // If null, the project has been modified last by the current user. + storageProvider: ?StorageProvider, + authenticatedUser: AuthenticatedUser, + currentFileMetadata: ?FileMetadata, + textColor?: 'primary' | 'secondary', + textPrefix?: React.Node, +|}; + +const LastModificationInfo = ({ + file, + lastModifiedInfo, + storageProvider, + authenticatedUser, + currentFileMetadata, + textColor = 'primary', + textPrefix, +}: LastModificationInfoProps) => { + const isProjectSavedOnCloud = + !!storageProvider && storageProvider.internalName === 'Cloud'; + const isCurrentProjectOpened = + !!currentFileMetadata && + currentFileMetadata.fileIdentifier === file.fileMetadata.fileIdentifier; + const lastModifiedAt = !!lastModifiedInfo + ? lastModifiedInfo.lastModifiedAt + : !!file.fileMetadata.lastModifiedDate + ? file.fileMetadata.lastModifiedDate + : null; + if (!lastModifiedAt) return null; + + // Current user info + const currentUserEmail = + authenticatedUser.profile && authenticatedUser.profile.email; + const currentUserUsername = + authenticatedUser.profile && authenticatedUser.profile.username; + const currentUserAvatarUrl = + isProjectSavedOnCloud && currentUserEmail + ? getGravatarUrl(currentUserEmail, { + size: 40, + }) + : null; + + // Last editor info + const lastEditorUsername = !!lastModifiedInfo + ? lastModifiedInfo.lastModifiedByUsername + : currentUserUsername; + const lastEditorAvatarUrl = !!lastModifiedInfo + ? lastModifiedInfo.lastModifiedByIconUrl + : currentUserAvatarUrl; + + const isProjectOpenedNotTheLatestVersion = + !!isCurrentProjectOpened && + !!currentFileMetadata && + !!lastModifiedInfo && + currentFileMetadata.version !== lastModifiedInfo.lastKnownVersionId; + + return ( + + {({ i18n }) => ( + + {textPrefix && ( + + {textPrefix} + + )} + {isCurrentProjectOpened && ( + + )} + {isProjectSavedOnCloud && + (!isCurrentProjectOpened || isProjectOpenedNotTheLatestVersion) && ( + + )} + + {isCurrentProjectOpened ? ( + Modifying + ) : ( + getRelativeOrAbsoluteDisplayDate({ + i18n, + dateAsNumber: lastModifiedAt, + sameDayFormat: 'todayAndHour', + dayBeforeFormat: 'yesterdayAndHour', + relativeLimit: 'currentWeek', + sameWeekFormat: 'thisWeek', + }) + )} + + + )} + + ); +}; + +export default LastModificationInfo; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js index 5c4c6b1823e4..2d8f8c617bb7 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js @@ -14,13 +14,7 @@ import { type StorageProvider, } from '../../../../ProjectsStorage'; import { type Game } from '../../../../Utils/GDevelopServices/Game'; -import PreferencesContext from '../../../Preferences/PreferencesContext'; import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext'; -import { - checkIfHasTooManyCloudProjects, - MaxProjectCountAlertMessage, -} from './MaxProjectCountAlertMessage'; -import { SubscriptionSuggestionContext } from '../../../../Profile/Subscription/SubscriptionSuggestionContext'; import Skeleton from '@material-ui/lab/Skeleton'; import BackgroundText from '../../../../UI/BackgroundText'; import Paper from '../../../../UI/Paper'; @@ -34,14 +28,13 @@ import { useProjectsListFor, } from './utils'; import ErrorBoundary from '../../../../UI/ErrorBoundary'; -import useAlertDialog from '../../../../UI/Alert/useAlertDialog'; import optionalRequire from '../../../../Utils/OptionalRequire'; -import { deleteCloudProject } from '../../../../Utils/GDevelopServices/Project'; -import { extractGDevelopApiErrorStatusAndCode } from '../../../../Utils/GDevelopServices/Errors'; import ContextMenu, { type ContextMenuInterface, } from '../../../../UI/Menu/ContextMenu'; import type { ClientCoordinates } from '../../../../Utils/UseLongTouch'; +import PreferencesContext from '../../../Preferences/PreferencesContext'; +import useAlertDialog from '../../../../UI/Alert/useAlertDialog'; const electron = optionalRequire('electron'); const path = optionalRequire('path'); @@ -64,11 +57,14 @@ const styles = { }; type Props = {| - i18n: I18nType, - - game: ?Game, + game: Game, onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, storageProviders: Array, + onDeleteCloudProject: ( + i18n: I18nType, + file: FileMetadataAndStorageProviderName + ) => Promise, + disabled: boolean, project: ?gdProject, currentFileMetadata: ?FileMetadata, @@ -88,26 +84,17 @@ const ProjectFileList = ({ game, onOpenProject, storageProviders, - i18n, closeProject, + onDeleteCloudProject, + disabled, }: Props) => { - const { - showDeleteConfirmation, - showConfirmation, - showAlert, - } = useAlertDialog(); const projectFiles = useProjectsListFor(game); - const { removeRecentProjectFile } = React.useContext(PreferencesContext); - const [pendingProject, setPendingProject] = React.useState(null); const contextMenu = React.useRef(null); + const { removeRecentProjectFile } = React.useContext(PreferencesContext); const authenticatedUser = React.useContext(AuthenticatedUserContext); - const { openSubscriptionDialog } = React.useContext( - SubscriptionSuggestionContext - ); const { profile, cloudProjects, - limits, cloudProjectsFetchingErrorLabel, onCloudProjectsChanged, } = authenticatedUser; @@ -116,6 +103,7 @@ const ProjectFileList = ({ lastModifiedInfoByProjectId, setLastModifiedInfoByProjectId, ] = React.useState({}); + const { showConfirmation } = useAlertDialog(); // Look at projects where lastCommittedBy is not the current user, and fetch // public profiles of the users that have modified them. @@ -138,63 +126,20 @@ const ProjectFileList = ({ [cloudProjects, profile] ); - const hasTooManyCloudProjects = checkIfHasTooManyCloudProjects( - authenticatedUser - ); - - const onDeleteCloudProject = async ( - i18n: I18nType, - { fileMetadata, storageProviderName }: FileMetadataAndStorageProviderName - ) => { - if (storageProviderName !== 'Cloud') return; - const projectName = fileMetadata.name; - if (!projectName) return; // Only cloud projects can be deleted, and all cloud projects have names. - - const isCurrentProjectOpened = - !!currentFileMetadata && - currentFileMetadata.fileIdentifier === fileMetadata.fileIdentifier; - if (isCurrentProjectOpened) { + const onRemoveRecentProjectFile = React.useCallback( + async (file: FileMetadataAndStorageProviderName) => { const result = await showConfirmation({ - title: t`Project is opened`, - message: t`You are about to delete the project ${projectName}, which is currently opened. If you proceed, the project will be closed and you will lose any unsaved changes. Do you want to proceed?`, - confirmButtonLabel: t`Continue`, + title: t`Remove project from list`, + message: t`You are about to remove "${ + file.fileMetadata.name + }" from the list of your projects.${'\n\n'}It will not delete it from your disk and you can always re-open it later. Do you want to proceed?`, + confirmButtonLabel: t`Remove`, }); if (!result) return; - await closeProject(); - } - - // Extract word translation to ensure it is not wrongly translated in the sentence. - const translatedConfirmText = i18n._(t`delete`); - - const deleteAnswer = await showDeleteConfirmation({ - title: t`Permanently delete the project?`, - message: t`Project ${projectName} will be deleted. You will no longer be able to access it.`, - fieldMessage: t`To confirm, type "${translatedConfirmText}"`, - confirmText: translatedConfirmText, - confirmButtonLabel: t`Delete project`, - }); - if (!deleteAnswer) return; - - try { - setPendingProject(fileMetadata.fileIdentifier); - await deleteCloudProject(authenticatedUser, fileMetadata.fileIdentifier); - authenticatedUser.onCloudProjectsChanged(); - } catch (error) { - const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( - error - ); - const message = - extractedStatusAndCode && extractedStatusAndCode.status === 403 - ? t`You don't have permissions to delete this project.` - : t`An error occurred when saving the project. Please try again later.`; - showAlert({ - title: t`Unable to delete the project`, - message, - }); - } finally { - setPendingProject(null); - } - }; + removeRecentProjectFile(file); + }, + [removeRecentProjectFile, showConfirmation] + ); const buildContextMenu = ( i18n: I18nType, @@ -206,10 +151,13 @@ const ProjectFileList = ({ { label: i18n._(t`Open`), click: () => onOpenProject(file) }, ]; if (file.storageProviderName === 'Cloud') { - actions.push({ - label: i18n._(t`Delete`), - click: () => onDeleteCloudProject(i18n, file), - }); + actions.push( + { type: 'separator' }, + { + label: i18n._(t`Delete`), + click: () => onDeleteCloudProject(i18n, file), + } + ); } else if (file.storageProviderName === 'LocalFile') { actions.push( ...[ @@ -217,17 +165,21 @@ const ProjectFileList = ({ label: i18n._(t`Show in local folder`), click: () => locateProjectFile(file), }, + { type: 'separator' }, { label: i18n._(t`Remove from list`), - click: () => removeRecentProjectFile(file), + click: () => onRemoveRecentProjectFile(file), }, ] ); } else { - actions.push({ - label: i18n._(t`Remove from list`), - click: () => removeRecentProjectFile(file), - }); + actions.push( + { type: 'separator' }, + { + label: i18n._(t`Remove from list`), + click: () => onRemoveRecentProjectFile(file), + } + ); } return actions; @@ -301,9 +253,7 @@ const ProjectFileList = ({ key={file.fileMetadata.fileIdentifier} file={file} onOpenContextMenu={openContextMenu} - isLoading={ - pendingProject === file.fileMetadata.fileIdentifier - } + isLoading={disabled} currentFileMetadata={currentFileMetadata} storageProviders={storageProviders} isWindowSizeMediumOrLarger={!isMobile} @@ -315,19 +265,6 @@ const ProjectFileList = ({ } /> ))} - {isMobile && limits && hasTooManyCloudProjects && ( - - openSubscriptionDialog({ - analyticsMetadata: { - reason: 'Cloud Project limit reached', - }, - }) - } - /> - )} diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileListItem.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileListItem.js index 16b68795fd7c..fcdce2a46403 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileListItem.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileListItem.js @@ -1,6 +1,5 @@ // @flow import * as React from 'react'; -import { Trans } from '@lingui/macro'; import { I18n } from '@lingui/react'; import ListItem from '@material-ui/core/ListItem'; @@ -11,11 +10,8 @@ import { type FileMetadataAndStorageProviderName, type StorageProvider, } from '../../../../ProjectsStorage'; -import AuthenticatedUserContext, { - type AuthenticatedUser, -} from '../../../../Profile/AuthenticatedUserContext'; +import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext'; import CircularProgress from '../../../../UI/CircularProgress'; -import { getRelativeOrAbsoluteDisplayDate } from '../../../../Utils/DateDisplay'; import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; import IconButton from '../../../../UI/IconButton'; import ThreeDotsMenu from '../../../../UI/CustomSvgIcons/ThreeDotsMenu'; @@ -23,16 +19,12 @@ import { useLongTouch, type ClientCoordinates, } from '../../../../Utils/UseLongTouch'; -import Avatar from '@material-ui/core/Avatar'; -import Tooltip from '@material-ui/core/Tooltip'; -import { getGravatarUrl } from '../../../../UI/GravatarUrl'; import { getStorageProviderByInternalName, type LastModifiedInfo, } from './utils'; -import DotBadge from '../../../../UI/DotBadge'; import { type FileMetadata } from '../../../../ProjectsStorage'; -import StatusIndicator from './StatusIndicator'; +import LastModificationInfo from './LastModificationInfo'; const styles = { listItem: { @@ -42,139 +34,11 @@ const styles = { }, projectSkeleton: { borderRadius: 6 }, noProjectsContainer: { padding: 10 }, - avatar: { - width: 20, - height: 20, - }, mobileIconContainer: { marginTop: 4, // To align with project title. }, }; -type AvatarWithStatusAndTooltipProps = {| - avatarUrl: ?string, - status: 'success' | 'error', - tooltipMessage: ?string, - hideStatus?: boolean, -|}; - -const AvatarWithStatusAndTooltip = ({ - avatarUrl, - status, - tooltipMessage, - hideStatus, -}: AvatarWithStatusAndTooltipProps) => - !!avatarUrl ? ( - tooltipMessage ? ( - - - - - - ) : ( - - - - ) - ) : ( - - ); - -type ListItemLastModificationProps = {| - file: FileMetadataAndStorageProviderName, - lastModifiedInfo?: LastModifiedInfo | null, // If null, the project has been modified last by the current user. - storageProvider: ?StorageProvider, - authenticatedUser: AuthenticatedUser, - currentFileMetadata: ?FileMetadata, - textColor?: 'primary' | 'secondary', -|}; - -const ListItemLastModification = ({ - file, - lastModifiedInfo, - storageProvider, - authenticatedUser, - currentFileMetadata, - textColor = 'primary', -}: ListItemLastModificationProps) => { - const isProjectSavedOnCloud = - !!storageProvider && storageProvider.internalName === 'Cloud'; - const isCurrentProjectOpened = - !!currentFileMetadata && - currentFileMetadata.fileIdentifier === file.fileMetadata.fileIdentifier; - const lastModifiedAt = !!lastModifiedInfo - ? lastModifiedInfo.lastModifiedAt - : !!file.fileMetadata.lastModifiedDate - ? file.fileMetadata.lastModifiedDate - : null; - if (!lastModifiedAt) return null; - - // Current user info - const currentUserEmail = - authenticatedUser.profile && authenticatedUser.profile.email; - const currentUserUsername = - authenticatedUser.profile && authenticatedUser.profile.username; - const currentUserAvatarUrl = - isProjectSavedOnCloud && currentUserEmail - ? getGravatarUrl(currentUserEmail, { - size: 40, - }) - : null; - - // Last editor info - const lastEditorUsername = !!lastModifiedInfo - ? lastModifiedInfo.lastModifiedByUsername - : currentUserUsername; - const lastEditorAvatarUrl = !!lastModifiedInfo - ? lastModifiedInfo.lastModifiedByIconUrl - : currentUserAvatarUrl; - - const isProjectOpenedNotTheLatestVersion = - !!isCurrentProjectOpened && - !!currentFileMetadata && - !!lastModifiedInfo && - currentFileMetadata.version !== lastModifiedInfo.lastKnownVersionId; - - return ( - - {({ i18n }) => ( - - {isCurrentProjectOpened && ( - - )} - {isProjectSavedOnCloud && - (!isCurrentProjectOpened || isProjectOpenedNotTheLatestVersion) && ( - - )} - - {isCurrentProjectOpened ? ( - Modifying - ) : ( - getRelativeOrAbsoluteDisplayDate({ - i18n, - dateAsNumber: lastModifiedAt, - sameDayFormat: 'todayAndHour', - dayBeforeFormat: 'yesterdayAndHour', - relativeLimit: 'currentWeek', - sameWeekFormat: 'thisWeek', - }) - )} - - - )} - - ); -}; - const PrettyBreakablePath = ({ path }: {| path: string |}) => { const separatorIndices = Array.from(path) .map((char, index) => (['/', '\\'].includes(char) ? index : null)) @@ -278,7 +142,7 @@ export const ProjectFileListItem = ({ - )} - - { + switch (windowSize) { + case 'small': + return isLandscape ? 4 : 2; + case 'medium': + return 3; + case 'large': + return 4; + case 'xlarge': + return 6; + default: + return 4; + } +}; + +type Props = {| + project: ?gdProject, + currentFileMetadata: ?FileMetadata, + onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, + storageProviders: Array, + closeProject: () => Promise, + canOpen: boolean, + onOpenProfile: () => void, + askToCloseProject: () => Promise, + onCreateProjectFromExample: ( + exampleShortHeader: ExampleShortHeader, + newProjectSetup: NewProjectSetup, + i18n: I18nType + ) => Promise, + onSelectPrivateGameTemplateListingData: ( + privateGameTemplateListingData: PrivateGameTemplateListingData + ) => void, + onSelectExampleShortHeader: (exampleShortHeader: ExampleShortHeader) => void, + i18n: I18nType, + games: ?Array, + onRefreshGames: () => Promise, + onGameUpdated: (game: Game) => void, + gamesFetchingError: ?Error, + openedGame: ?Game, + setOpenedGameId: (gameId: ?string) => void, + currentTab: GameDetailsTab, + setCurrentTab: GameDetailsTab => void, + onOpenNewProjectSetupDialog: () => void, + onChooseProject: () => void, + onSaveProject: () => Promise, + canSaveProject: boolean, +|}; + +const CreateSection = ({ + project, + currentFileMetadata, + onOpenProject, + storageProviders, + closeProject, + canOpen, + onOpenProfile, + askToCloseProject, + onCreateProjectFromExample, + onSelectPrivateGameTemplateListingData, + onSelectExampleShortHeader, + i18n, + games, + onRefreshGames, + onGameUpdated, + gamesFetchingError, + openedGame, + setOpenedGameId, + currentTab, + setCurrentTab, + onOpenNewProjectSetupDialog, + onChooseProject, + onSaveProject, + canSaveProject, +}: Props) => { + const authenticatedUser = React.useContext(AuthenticatedUserContext); + const { + profile, + getAuthorizationHeader, + loginState, + recommendations, + limits, + } = authenticatedUser; + const { + showDeleteConfirmation, + showConfirmation, + showAlert, + } = useAlertDialog(); + const [isUpdatingGame, setIsUpdatingGame] = React.useState(false); + const [initialWidgetToScrollTo, setInitialWidgetToScrollTo] = React.useState( + null + ); + const [showAllGameTemplates, setShowAllGameTemplates] = React.useState(false); + const { routeArguments, removeRouteArguments } = React.useContext( + RouterContext + ); + const { openSubscriptionDialog } = React.useContext( + SubscriptionSuggestionContext + ); + // $FlowIgnore + const quickCustomizationRecommendation: ?QuickCustomizationRecommendation = React.useMemo( + () => { + return recommendations + ? recommendations.find( + recommendation => recommendation.type === 'quick-customization' + ) + : null; + }, + [recommendations] + ); + const { windowSize, isMobile, isLandscape } = useResponsiveWindowSize(); + const isMobileOrMediumWidth = + windowSize === 'small' || windowSize === 'medium'; + const hasTooManyCloudProjects = checkIfHasTooManyCloudProjects( + authenticatedUser + ); + const allRecentProjectFiles = useProjectsListFor(null); + const hasAProjectOpenedOrSavedOrGameRegistered = + !!project || (!!games && games.length) || !!allRecentProjectFiles.length; + const hidePerformanceDashboard = + !!limits && + !!limits.capabilities.classrooms && + limits.capabilities.classrooms.hideSocials; + + React.useEffect( + () => { + onRefreshGames(); + }, + // Refresh the games when the callback changes (defined in useGamesList), that's + // to say when the user profile changes. + [onRefreshGames] + ); + + React.useEffect( + () => { + if (openedGame && !profile) { + setOpenedGameId(null); + } + }, + // Close game view if user logs out. + [profile, openedGame, setOpenedGameId] + ); + + const [currentPage, setCurrentPage] = React.useState(1); + const [searchText, setSearchText] = React.useState(''); + + const onUnregisterGame = React.useCallback( + async ( + gameId: string, + i18n: I18nType, + options?: { skipConfirmation: boolean, throwOnError: boolean } + ) => { + if (!profile) return; + + if (!options || !options.skipConfirmation) { + const answer = await showConfirmation({ + title: t`Unregister game`, + message: t`Are you sure you want to unregister this game?${'\n\n'}If you haven't saved it, it will disappear from your games dashboard and you won't get access to player services, unless you register it again.`, + }); + if (!answer) return; + } + + const { id } = profile; + setIsUpdatingGame(true); + try { + await deleteGame(getAuthorizationHeader, id, gameId); + if (openedGame && openedGame.id === gameId) { + setOpenedGameId(null); + } + } catch (error) { + console.error('Unable to delete the game:', error); + const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( + error + ); + if ( + extractedStatusAndCode && + extractedStatusAndCode.code === 'game-deletion/leaderboards-exist' + ) { + await showAlert({ + title: t`Unable to unregister the game`, + message: t`You cannot unregister a game that has active leaderboards. To delete them, access the player services, and delete them one by one.`, + }); + } else { + await showAlert({ + title: t`Unable to unregister the game`, + message: t`An error happened while unregistering the game. Verify your internet connection or retry later.`, + }); + } + + if (options && options.throwOnError) { + throw error; + } + } finally { + setIsUpdatingGame(false); + } + + onRefreshGames(); + }, + [ + openedGame, + profile, + getAuthorizationHeader, + onRefreshGames, + setOpenedGameId, + showConfirmation, + showAlert, + ] + ); + + React.useEffect( + () => { + const loadInitialGame = async () => { + // When games are loaded and we have an initial game id, try to open it. + const initialGameId = routeArguments['game-id']; + if (games && initialGameId) { + const game = games.find(game => game.id === initialGameId); + removeRouteArguments(['game-id']); + if (game) { + setOpenedGameId(game.id); + } else { + await showAlert({ + title: t`Game not found`, + message: t`The game you're trying to open is not registered online. Open the project + file, then register it before continuing.`, + }); + } + } + }; + loadInitialGame(); + }, + [ + games, + routeArguments, + removeRouteArguments, + showConfirmation, + showAlert, + project, + setOpenedGameId, + ] + ); + + const onBack = React.useCallback( + () => { + setCurrentTab('details'); + setOpenedGameId(null); + }, + [setCurrentTab, setOpenedGameId] + ); + + const onDeleteCloudProject = async ( + i18n: I18nType, + { fileMetadata, storageProviderName }: FileMetadataAndStorageProviderName, + options?: { skipConfirmation: boolean } + ) => { + if (storageProviderName !== 'Cloud') return; + const projectName = fileMetadata.name; + if (!projectName) return; // Only cloud projects can be deleted, and all cloud projects have names. + + const isCurrentProjectOpened = + !!project && + !!currentFileMetadata && + fileMetadata.gameId === currentFileMetadata.gameId; + + if (isCurrentProjectOpened) { + const result = await showConfirmation({ + title: t`Project is opened`, + message: t`You are about to delete the project ${projectName}, which is currently opened. If you proceed, the project will be closed and you will lose any unsaved changes. Do you want to proceed?`, + confirmButtonLabel: t`Continue`, + }); + if (!result) return; + await closeProject(); + } + + if (!options || !options.skipConfirmation) { + // Extract word translation to ensure it is not wrongly translated in the sentence. + const translatedConfirmText = i18n._(t`delete`); + + const deleteAnswer = await showDeleteConfirmation({ + title: t`Permanently delete the project?`, + message: t`Project ${projectName} will be deleted. You will no longer be able to access it.`, + fieldMessage: t`To confirm, type "${translatedConfirmText}"`, + confirmText: translatedConfirmText, + confirmButtonLabel: t`Delete project`, + }); + if (!deleteAnswer) return; + } + + try { + setIsUpdatingGame(true); + await deleteCloudProject(authenticatedUser, fileMetadata.fileIdentifier); + authenticatedUser.onCloudProjectsChanged(); + } catch (error) { + const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( + error + ); + const message = + extractedStatusAndCode && extractedStatusAndCode.status === 403 + ? t`You don't have permissions to delete this project.` + : t`An error occurred when deleting the project. Please try again later.`; + showAlert({ + title: t`Unable to delete the project`, + message, + }); + } finally { + setIsUpdatingGame(false); + } + }; + + const onRegisterProject = React.useCallback( + async (file: FileMetadataAndStorageProviderName): Promise => { + const projectId = file.fileMetadata.gameId; + + if (!authenticatedUser.profile) return; + + if (!projectId) { + console.error('No project id found for registering the game.'); + showAlert({ + title: t`Unable to register the game`, + message: t`An error happened while registering the game. Verify your internet connection + or retry later.`, + }); + return; + } + + const { id, username } = authenticatedUser.profile; + try { + setIsUpdatingGame(true); + const game = await registerGame( + authenticatedUser.getAuthorizationHeader, + id, + getDefaultRegisterGameProperties({ + projectId, + projectName: file.fileMetadata.name, + projectAuthor: username, + // A project is always saved when appearing in the list of recent projects. + savedStatus: 'saved', + }) + ); + return game; + } catch (error) { + console.error('Unable to register the game.', error); + const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( + error + ); + if (extractedStatusAndCode && extractedStatusAndCode.status === 403) { + await showAlert({ + title: t`Game already registered`, + message: t`This game is registered online but you don't have + access to it. Ask the owner of the game to add your account to the list of owners to be able to manage it.`, + }); + } else { + await showAlert({ + title: t`Unable to register the game`, + message: t`An error happened while registering the game. Verify your internet connection + or retry later.`, + }); + } + } finally { + setIsUpdatingGame(false); + } + }, + [ + authenticatedUser.getAuthorizationHeader, + authenticatedUser.profile, + showAlert, + ] + ); + + if (openedGame) { + return ( + + onUnregisterGame(openedGame.id, i18n)} + onDeleteCloudProject={onDeleteCloudProject} + initialWidgetToScrollTo={initialWidgetToScrollTo} + /> + + ); + } + + if (showAllGameTemplates) { + return ( + setShowAllGameTemplates(false)} + flexBody + > + + + + + ); + } + + return ( + + {({ i18n }) => ( + ( + + + + openSubscriptionDialog({ + analyticsMetadata: { + reason: 'Cloud Project limit reached', + }, + }) + } + /> + + + ) + : undefined + } + > + + {!!profile || loginState === 'done' ? ( + + {hidePerformanceDashboard ? null : hasAProjectOpenedOrSavedOrGameRegistered ? ( + + + + + + + ) : ( + + + + )} + + { + setInitialWidgetToScrollTo(widgetToScrollTo); + setOpenedGameId(game.id); + }} + onOpenProject={onOpenProject} + isUpdatingGame={isUpdatingGame} + onUnregisterGame={onUnregisterGame} + canOpen={canOpen} + onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog} + onChooseProject={onChooseProject} + currentFileMetadata={currentFileMetadata} + closeProject={closeProject} + askToCloseProject={askToCloseProject} + onSaveProject={onSaveProject} + canSaveProject={canSaveProject} + onDeleteCloudProject={onDeleteCloudProject} + onRegisterProject={onRegisterProject} + // Controls + currentPage={currentPage} + setCurrentPage={setCurrentPage} + searchText={searchText} + setSearchText={setSearchText} + /> + {isMobile && limits && hasTooManyCloudProjects && ( + + openSubscriptionDialog({ + analyticsMetadata: { + reason: 'Cloud Project limit reached', + }, + }) + } + /> + )} + {quickCustomizationRecommendation && ( + + + + Remix a game in 2 minutes + + + { + const projectIsClosed = await askToCloseProject(); + if (!projectIsClosed) { + return; + } + + const newProjectSetup: NewProjectSetup = { + storageProvider: UrlStorageProvider, + saveAsLocation: null, + openQuickCustomizationDialog: true, + }; + onCreateProjectFromExample( + exampleShortHeader, + newProjectSetup, + i18n + ); + }} + quickCustomizationRecommendation={ + quickCustomizationRecommendation + } + /> + + )} + {!hasAProjectOpenedOrSavedOrGameRegistered && ( + + + + Start from a template + + setShowAllGameTemplates(true)} + label={ + isMobile ? ( + Browse + ) : ( + Browse all templates + ) + } + leftIcon={} + /> + + + + )} + + ) : gamesFetchingError ? ( + + + Can't load the games. Verify your internet connection or retry + later. + + + ) : ( + + + + )} + + + )} + + ); +}; + +const CreateSectionWithErrorBoundary = (props: Props) => ( + Create section} + scope="start-page-create" + > + + +); + +export default CreateSectionWithErrorBoundary; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js index c9bbd5f4b926..ff689635fbd9 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js @@ -30,7 +30,7 @@ export type LastModifiedInfo = {| lastKnownVersionId: ?string, |}; -type LastModifiedInfoByProjectId = {| +export type LastModifiedInfoByProjectId = {| [projectId: string]: LastModifiedInfo, |}; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js deleted file mode 100644 index f467b583439f..000000000000 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js +++ /dev/null @@ -1,185 +0,0 @@ -// @flow -import * as React from 'react'; -import { Trans } from '@lingui/macro'; -import Text from '../../../../UI/Text'; -import { - ColumnStackLayout, - LineStackLayout, - ResponsiveLineStackLayout, -} from '../../../../UI/Layout'; -import { - type Badge, - type Achievement, -} from '../../../../Utils/GDevelopServices/Badge'; -import { Column, LargeSpacer } from '../../../../UI/Grid'; -import RaisedButton from '../../../../UI/RaisedButton'; -import Window from '../../../../Utils/Window'; -import Coin from '../../../../Credits/Icons/Coin'; -import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale'; -import { I18n } from '@lingui/react'; -import CreditsStatusBanner from '../../../../Credits/CreditsStatusBanner'; - -type Props = {| - achievements: ?Array, - badges: ?Array, - onOpenProfile: () => void, -|}; - -const getAchievement = (achievements: ?Array, id: string) => - achievements && achievements.find(achievement => achievement.id === id); - -const hasBadge = (badges: ?Array, achievementId: string) => - !!badges && badges.some(badge => badge.achievementId === achievementId); - -const styles = { - badgeContainer: { - position: 'relative', - width: 65, - height: 65, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - badgeImage: { - position: 'absolute', - inset: 0, - width: '100%', - height: '100%', - objectFit: 'contain', - }, - badgeCoinIcon: { - width: 16, - height: 16, - }, - badgeTextContainer: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - zIndex: 1, - gap: 4, - color: 'white', - }, -}; - -const BadgeItem = ({ - achievements, - badges, - achievementId, - buttonLabel, - linkUrl, -}: {| - achievements: ?Array, - badges: ?Array, - achievementId: string, - buttonLabel: React.Node, - linkUrl: string, -|}) => { - const achievement = getAchievement(achievements, achievementId); - const hasThisBadge = hasBadge(badges, achievementId); - - return ( - - {({ i18n }) => ( - -
- Empty badge - {!hasThisBadge && ( -
- - - {achievement ? achievement.rewardValueInCredits : ''}{' '} - -
- )} -
- - - - - - {(achievement && - selectMessageByLocale(i18n, achievement.nameByLocale)) || - '-'} - - - - - - {(achievement && - selectMessageByLocale( - i18n, - achievement.shortDescriptionByLocale - )) || - '-'} - - - - - Worth {achievement ? achievement.rewardValueInCredits : '-'}{' '} - credits. - - - - { - Window.openExternalURL(linkUrl); - }} - disabled={hasThisBadge} - /> - -
- )} -
- ); -}; - -export const EarnBadges = ({ achievements, badges, onOpenProfile }: Props) => { - return ( - - Claim credits} - onActionButtonClick={onOpenProfile} - /> - - - Star GDevelop} - linkUrl={'https://github.com/4ian/GDevelop'} - /> - Follow} - linkUrl={'https://www.tiktok.com/@gdevelop'} - /> - Follow} - linkUrl={'https://x.com/GDevelopApp'} - /> - - - ); -}; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits.js new file mode 100644 index 000000000000..ae1ecf8a6a44 --- /dev/null +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits.js @@ -0,0 +1,373 @@ +// @flow +import * as React from 'react'; +import { Trans } from '@lingui/macro'; +import Text from '../../../../UI/Text'; +import { + LineStackLayout, + ResponsiveLineStackLayout, +} from '../../../../UI/Layout'; +import { + type Badge, + type Achievement, +} from '../../../../Utils/GDevelopServices/Badge'; +import { Column } from '../../../../UI/Grid'; +import Window from '../../../../Utils/Window'; +import Coin from '../../../../Credits/Icons/Coin'; +import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale'; +import { I18n } from '@lingui/react'; +import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer'; +import TextButton from '../../../../UI/TextButton'; + +type CreditItemType = 'badge' | 'feedback'; +type BadgeInfo = {| + id: string, + label: React.Node, + linkUrl: string, + hasThisBadge?: boolean, + type: 'badge', +|}; +type FeedbackInfo = {| + id: string, + type: 'feedback', +|}; +type CreditItem = BadgeInfo | FeedbackInfo; + +const styles = { + badgeContainer: { + position: 'relative', + width: 65, + height: 65, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + badgeImage: { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + objectFit: 'contain', + }, + badgeCoinIcon: { + width: 16, + height: 16, + }, + badgeTextContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + gap: 4, + color: 'white', + }, + itemPlaceholder: { + display: 'flex', + flex: 1, + }, +}; + +const FeedbackItem = () => { + return ( + +
+ Empty badge +
+ + + 10 + +
+
+ + + + Community helper + + + + Give feedback on a game! + + + Play a game} + secondary + onClick={() => { + Window.openExternalURL('https://gd.games/games/random'); + }} + /> +
+ ); +}; + +const allBadgesInfo: BadgeInfo[] = [ + { + id: 'github-star', + label: Star GDevelop, + linkUrl: 'https://github.com/4ian/GDevelop', + type: 'badge', + }, + { + id: 'tiktok-follow', + label: Follow, + linkUrl: 'https://www.tiktok.com/@gdevelop', + type: 'badge', + }, + { + id: 'twitter-follow', + label: Follow, + linkUrl: 'https://x.com/GDevelopApp', + type: 'badge', + }, +]; + +const getAllBadgesWithOwnedStatus = (badges: ?Array): BadgeInfo[] => { + return allBadgesInfo.map(badgeInfo => ({ + ...badgeInfo, + hasThisBadge: hasBadge(badges, badgeInfo.id), + })); +}; + +const getAchievement = (achievements: ?Array, id: string) => + achievements && achievements.find(achievement => achievement.id === id); + +const hasBadge = (badges: ?Array, achievementId: string) => + !!badges && badges.some(badge => badge.achievementId === achievementId); + +export const hasMissingBadges = ( + badges: ?Array, + achievements: ?Array +) => + // Not connected + !badges || + !achievements || + // Connected but some achievements are not yet claimed + achievements.some(achievement => !hasBadge(badges, achievement.id)); + +const BadgeItem = ({ + achievement, + hasThisBadge, + buttonLabel, + linkUrl, +}: {| + achievement: ?Achievement, + hasThisBadge: boolean, + buttonLabel: React.Node, + linkUrl: string, +|}) => { + return ( + + {({ i18n }) => ( + +
+ Empty badge + {!hasThisBadge && ( +
+ + + {achievement ? achievement.rewardValueInCredits : ''}{' '} + +
+ )} +
+ + + + + {(achievement && + selectMessageByLocale(i18n, achievement.nameByLocale)) || + '-'} + + + + + + {(achievement && + selectMessageByLocale( + i18n, + achievement.shortDescriptionByLocale + )) || + '-'} + + + + { + Window.openExternalURL(linkUrl); + }} + disabled={hasThisBadge} + /> +
+ )} +
+ ); +}; + +type Props = {| + achievements: ?Array, + badges: ?Array, + onOpenProfile: () => void, + showRandomItem?: boolean, + showAllItems?: boolean, +|}; + +export const EarnCredits = ({ + achievements, + badges, + onOpenProfile, + showRandomItem, + showAllItems, +}: Props) => { + const { windowSize, isMobile } = useResponsiveWindowSize(); + const isMobileOrMediumWidth = + windowSize === 'small' || windowSize === 'medium'; + + const allBadgesWithOwnedStatus = React.useMemo( + () => getAllBadgesWithOwnedStatus(badges), + [badges] + ); + + const missingBadges = React.useMemo( + () => { + return allBadgesWithOwnedStatus.filter(badge => !badge.hasThisBadge); + }, + [allBadgesWithOwnedStatus] + ); + + const randomItemToShow: ?CreditItemType = React.useMemo( + () => { + // If on mobile, and not forcing all items, show only 1 item to avoid taking too much space. + if (showRandomItem || (isMobile && !showAllItems)) { + if (missingBadges.length === 0) { + return 'feedback'; + } + + const totalPossibilities = missingBadges.length + 1; // +1 for feedback + // Randomize between badge and feedback, with the weight of the number of badges missing. + const randomIndex = Math.floor(Math.random() * totalPossibilities); + if (randomIndex === totalPossibilities - 1) { + return 'feedback'; + } + + return 'badge'; + } + + return null; + }, + [missingBadges, showRandomItem, showAllItems, isMobile] + ); + + const badgesToShow = React.useMemo( + () => { + if (!!randomItemToShow && randomItemToShow !== 'badge') { + return []; + } + + if (randomItemToShow === 'badge') { + if (missingBadges.length === 0) { + const randomIndex = Math.floor( + Math.random() * allBadgesWithOwnedStatus.length + ); + return [allBadgesWithOwnedStatus[randomIndex]]; + } + + const randomIndex = Math.floor(Math.random() * missingBadges.length); + return [missingBadges[randomIndex]]; + } + + return allBadgesWithOwnedStatus; + }, + [allBadgesWithOwnedStatus, missingBadges, randomItemToShow] + ); + + const feedbackItemsToShow: FeedbackInfo[] = React.useMemo( + () => { + if (!!randomItemToShow && randomItemToShow !== 'feedback') { + return []; + } + + return [ + { + id: 'random-game-feedback', + type: 'feedback', + }, + ]; + }, + [randomItemToShow] + ); + + const allItemsToShow: CreditItem[] = React.useMemo( + () => [...badgesToShow, ...feedbackItemsToShow], + [badgesToShow, feedbackItemsToShow] + ); + + // Slice items in arrays of two to display them in a responsive way. + const itemsSlicedInArraysOfTwo: CreditItem[][] = React.useMemo( + () => { + const slicedItems: CreditItem[][] = []; + for (let i = 0; i < allItemsToShow.length; i += 2) { + slicedItems.push(allItemsToShow.slice(i, i + 2)); + } + return slicedItems; + }, + [allItemsToShow] + ); + + const onlyOneItemDisplayed = allItemsToShow.length === 1; + + return ( + + + {itemsSlicedInArraysOfTwo.map((items, index) => ( + + {items + .map(item => { + if (item.type === 'feedback') { + return ; + } + + if (item.type === 'badge') { + return ( + + ); + } + + return null; + }) + .filter(Boolean)} + {items.length === 1 && + !onlyOneItemDisplayed && + isMobileOrMediumWidth &&
} + + ))} + + + ); +}; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/RecommendationList.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/RecommendationList.js index ffba7897e690..e30bc7d772f0 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/RecommendationList.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/RecommendationList.js @@ -35,13 +35,10 @@ import { SurveyCard } from './SurveyCard'; import PlaceholderLoader from '../../../../UI/PlaceholderLoader'; import PromotionsSlideshow from '../../../../Promotions/PromotionsSlideshow'; import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog'; -import { EarnBadges } from './EarnBadges'; import FlatButton from '../../../../UI/FlatButton'; import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext'; -import { QuickCustomizationGameTiles } from '../../../../QuickCustomization/QuickCustomizationGameTiles'; import { type NewProjectSetup } from '../../../../ProjectCreation/NewProjectSetupDialog'; import { type ExampleShortHeader } from '../../../../Utils/GDevelopServices/Example'; -import UrlStorageProvider from '../../../../ProjectsStorage/UrlStorageProvider'; import { selectMessageByLocale } from '../../../../Utils/i18n/MessageByLocale'; const styles = { @@ -211,13 +208,7 @@ const RecommendationList = ({ onCreateProjectFromExample, askToCloseProject, }: Props) => { - const { - recommendations, - subscription, - limits, - badges, - achievements, - } = authenticatedUser; + const { recommendations, subscription, limits } = authenticatedUser; const { tutorials } = React.useContext(TutorialContext); const { getTutorialProgress, @@ -264,11 +255,6 @@ const RecommendationList = ({ recommendation => recommendation.type === 'plan' ); - // $FlowIgnore - const quickCustomizationRecommendation: ?QuickCustomizationRecommendation = recommendations.find( - recommendation => recommendation.type === 'quick-customization' - ); - const getInAppTutorialPartProgress = ({ tutorialId, }: { @@ -299,59 +285,6 @@ const RecommendationList = ({ ); - if (quickCustomizationRecommendation) { - items.push( - - - Create your game in 1 minute - - - { - const projectIsClosed = await askToCloseProject(); - if (!projectIsClosed) { - return; - } - - const newProjectSetup: NewProjectSetup = { - storageProvider: UrlStorageProvider, - saveAsLocation: null, - openQuickCustomizationDialog: true, - }; - onCreateProjectFromExample( - exampleShortHeader, - newProjectSetup, - i18n - ); - }} - /> - - ); - } - - if ( - !limits || - !limits.capabilities.classrooms || - !limits.capabilities.classrooms.hidePlayTab - ) { - items.push( - - - Earn badges and credits - - - - - ); - } - if (guidedLessonsRecommendation) { const displayTextAfterGuidedLessons = guidedLessonsIds ? guidedLessonsIds diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenu.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenu.js index ac150e3fae46..3566c96b0d26 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenu.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenu.js @@ -21,7 +21,6 @@ import { type Limits, } from '../../../Utils/GDevelopServices/Usage'; import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; -import GraphsIcon from '../../../UI/CustomSvgIcons/Graphs'; import { isNativeMobileApp } from '../../../Utils/Platform'; export const styles = { @@ -44,8 +43,7 @@ export const styles = { export type HomeTab = | 'get-started' - | 'manage' - | 'build' + | 'create' | 'learn' | 'play' | 'shop' @@ -63,7 +61,7 @@ export type HomePageMenuTab = {| id: string, |}; -const homePageMenuTabs: { [tab: string]: HomePageMenuTab } = { +const homePageMenuTabs: { [tab: HomeTab]: HomePageMenuTab } = { 'get-started': { label: Get Started, tab: 'get-started', @@ -72,22 +70,14 @@ const homePageMenuTabs: { [tab: string]: HomePageMenuTab } = { ), }, - build: { - label: Build, - tab: 'build', - id: 'home-build-tab', + create: { + label: Create, + tab: 'create', + id: 'home-create-tab', getIcon: ({ color, fontSize }) => ( ), }, - manage: { - label: Manage, - tab: 'manage', - id: 'home-manage-tab', - getIcon: ({ color, fontSize }) => ( - - ), - }, shop: { label: Shop, tab: 'shop', @@ -139,13 +129,12 @@ export const getTabsToDisplay = ({ limits.capabilities.classrooms && limits.capabilities.classrooms.hidePremiumProducts ); - const tabs = [ + const tabs: HomeTab[] = [ 'get-started', - 'build', + 'create', !shouldHideClassroomTab(limits) && !isNativeMobileApp() ? 'team-view' : null, - 'manage', displayShopTab ? 'shop' : null, 'learn', displayPlayTab ? 'play' : null, diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js index 0e422bb03fc4..f92edcbcf3c8 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js @@ -19,6 +19,7 @@ import { } from './HomePageMenu'; import { Toolbar, ToolbarGroup } from '../../../UI/Toolbar'; import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; +import { SECTION_DESKTOP_SPACING } from './SectionContainer'; const iconSize = 20; const iconButtonPaddingTop = 8; @@ -40,7 +41,7 @@ export const homepageMediumMenuBarWidth = export const styles = { desktopMenu: { - paddingTop: 40, + paddingTop: SECTION_DESKTOP_SPACING, // To align with the top of the sections paddingBottom: 10, minWidth: homepageDesktopMenuBarWidth, display: 'flex', diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/InAppTutorials/GuidedLessons.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/InAppTutorials/GuidedLessons.js index bce4f8e46741..7baf42c9bea3 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/InAppTutorials/GuidedLessons.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/InAppTutorials/GuidedLessons.js @@ -98,7 +98,11 @@ const GuidedLessons = ({ selectInAppTutorial, lessonsIds }: Props) => { const authenticatedUser = React.useContext(AuthenticatedUserContext); const { windowSize, isLandscape } = useResponsiveWindowSize(); - const getTutorialPartProgress = ({ tutorialId }: { tutorialId: string }) => { + const getTutorialPartProgress = ({ + tutorialId, + }: {| + tutorialId: string, + |}) => { const tutorialProgress = getTutorialProgress({ tutorialId, userId: authenticatedUser.profile diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/LearnSection/EducationCurriculumLesson.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/LearnSection/EducationCurriculumLesson.js index 7f1191153de9..248f6241a9d5 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/LearnSection/EducationCurriculumLesson.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/LearnSection/EducationCurriculumLesson.js @@ -79,7 +79,7 @@ const LockedOverlay = () => (
); -const UpcomingOverlay = ({ message }: { message: React.Node }) => ( +const UpcomingOverlay = ({ message }: {| message: React.Node |}) => (
{message}
diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/ManageSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/ManageSection/index.js deleted file mode 100644 index 8a79ffb27e87..000000000000 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/ManageSection/index.js +++ /dev/null @@ -1,425 +0,0 @@ -// @flow -import * as React from 'react'; -import { t, Trans } from '@lingui/macro'; -import { I18n as I18nType } from '@lingui/core'; -import SectionContainer, { SectionRow } from '../SectionContainer'; -import ErrorBoundary from '../../../../UI/ErrorBoundary'; -import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext'; -import GamesList from '../../../../GameDashboard/GamesList'; -import { - deleteGame, - registerGame, - type Game, -} from '../../../../Utils/GDevelopServices/Game'; -import PlaceholderError from '../../../../UI/PlaceholderError'; -import PlaceholderLoader from '../../../../UI/PlaceholderLoader'; -import { Column, Line } from '../../../../UI/Grid'; -import Paper from '../../../../UI/Paper'; -import BackgroundText from '../../../../UI/BackgroundText'; -import { - ColumnStackLayout, - ResponsiveLineStackLayout, -} from '../../../../UI/Layout'; -import RaisedButton from '../../../../UI/RaisedButton'; -import FlatButton from '../../../../UI/FlatButton'; -import Link from '../../../../UI/Link'; -import Window from '../../../../Utils/Window'; -import { getHelpLink } from '../../../../Utils/HelpLink'; -import { type GameDetailsTab } from '../../../../GameDashboard'; -import GameDashboard from '../../../../GameDashboard'; -import useAlertDialog from '../../../../UI/Alert/useAlertDialog'; -import RouterContext from '../../../RouterContext'; -import { getDefaultRegisterGamePropertiesFromProject } from '../../../../Utils/UseGameAndBuildsManager'; -import { extractGDevelopApiErrorStatusAndCode } from '../../../../Utils/GDevelopServices/Errors'; -import { GameRegistration } from '../../../../GameDashboard/GameRegistration'; -import UserEarnings from '../../../../GameDashboard/Monetization/UserEarnings'; -import { showErrorBox } from '../../../../UI/Messages/MessageBox'; -import { - type FileMetadataAndStorageProviderName, - type FileMetadata, - type StorageProvider, -} from '../../../../ProjectsStorage'; - -const publishingWikiArticle = getHelpLink('/publishing/'); - -const styles = { - backgroundMessage: { padding: 16 }, - buttonContainer: { minWidth: 150 }, - gameDetailsContainer: { padding: 8, flex: 1, display: 'flex' }, -}; - -type Props = {| - project: ?gdProject, - currentFileMetadata: ?FileMetadata, - onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, - storageProviders: Array, - closeProject: () => Promise, - - games: ?Array, - onRefreshGames: () => Promise, - onGameUpdated: (game: Game) => void, - gamesFetchingError: ?Error, - openedGame: ?Game, - setOpenedGameId: (gameId: ?string) => void, - currentTab: GameDetailsTab, - setCurrentTab: GameDetailsTab => void, -|}; - -const ManageSection = ({ - project, - currentFileMetadata, - onOpenProject, - storageProviders, - closeProject, - - games, - onRefreshGames, - onGameUpdated, - gamesFetchingError, - openedGame, - setOpenedGameId, - currentTab, - setCurrentTab, -}: Props) => { - const authenticatedUser = React.useContext(AuthenticatedUserContext); - const { - profile, - onOpenCreateAccountDialog, - onOpenLoginDialog, - getAuthorizationHeader, - } = authenticatedUser; - const { showAlert, showConfirmation } = useAlertDialog(); - const [isRegisteringGame, setIsRegisteringGame] = React.useState(false); - const [ - gameUnregisterErrorText, - setGameUnregisterErrorText, - ] = React.useState(null); - const { routeArguments, removeRouteArguments } = React.useContext( - RouterContext - ); - - React.useEffect( - () => { - onRefreshGames(); - }, - // Refresh the games when the callback changes (defined in useGamesList), that's - // to say when the user profile changes. - [onRefreshGames] - ); - - React.useEffect( - () => { - if (openedGame && !profile) { - setOpenedGameId(null); - } - }, - // Close game view is user logs out. - [profile, openedGame, setOpenedGameId] - ); - - const onRegisterGame = React.useCallback( - async () => { - if (!profile || !project) return; - - const { id } = profile; - try { - setIsRegisteringGame(true); - await registerGame( - getAuthorizationHeader, - id, - getDefaultRegisterGamePropertiesFromProject({ project }) - ); - await onRefreshGames(); - } catch (error) { - console.error('Unable to register the game.', error); - const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( - error - ); - if (extractedStatusAndCode && extractedStatusAndCode.status === 403) { - await showAlert({ - title: t`Game already registered`, - message: t`The project currently opened is registered online but you don't have - access to it. Ask the original owner of the game to share it with you - to be able to manage it.`, - }); - } else { - await showAlert({ - title: t`Unable to register the game`, - message: t`An error happened while registering the game. Verify your internet connection - or retry later.`, - }); - } - } finally { - setIsRegisteringGame(false); - } - }, - [getAuthorizationHeader, profile, project, showAlert, onRefreshGames] - ); - - const unregisterGame = React.useCallback( - async (i18n: I18nType) => { - if (!profile || !openedGame) return; - - const answer = await showConfirmation({ - title: t`Unregister game`, - message: t`Are you sure you want to unregister this game?${'\n\n'}It will disappear from your games dashboard and you won't get access to player services, unless you register it again.`, - }); - if (!answer) return; - - const { id } = profile; - setGameUnregisterErrorText(null); - try { - await deleteGame(getAuthorizationHeader, id, openedGame.id); - setOpenedGameId(null); - } catch (error) { - console.error('Unable to delete the game:', error); - const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( - error - ); - if ( - extractedStatusAndCode && - extractedStatusAndCode.code === 'game-deletion/leaderboards-exist' - ) { - setGameUnregisterErrorText( - i18n._( - t`You cannot unregister a game that has active leaderboards. To delete them, go in the Leaderboards tab, and delete them one by one.` - ) - ); - } else { - showErrorBox({ - message: - i18n._(t`Unable to unregister the game.`) + - ' ' + - i18n._(t`Verify your internet connection or try again later.`), - rawError: error, - errorId: 'game-dashboard-unregister-game', - }); - } - } - onRefreshGames(); - }, - [ - openedGame, - profile, - getAuthorizationHeader, - onRefreshGames, - setOpenedGameId, - showConfirmation, - ] - ); - - React.useEffect( - () => { - const loadInitialGame = async () => { - // When games are loaded and we have an initial game id, try to open it. - const initialGameId = routeArguments['game-id']; - if (games && initialGameId) { - const game = games.find(game => game.id === initialGameId); - removeRouteArguments(['game-id']); - if (game) { - setOpenedGameId(game.id); - } else { - // If the game is not in the list, then either - // - allow to register it, if it's the current project. - // - suggest to open the file before continuing, if it's not the current project. - if (project && project.getProjectUuid() === initialGameId) { - const answer = await showConfirmation({ - title: t`Game not found`, - message: t`This project is not registered online. Register it now - to get access to leaderboards, player accounts, analytics and more!`, - confirmButtonLabel: t`Register`, - }); - if (!answer) return; - - await onRegisterGame(); - } else { - await showAlert({ - title: t`Game not found`, - message: t`The game you're trying to open is not registered online. Open the project - file, then register it before continuing.`, - }); - } - } - } - }; - loadInitialGame(); - }, - [ - games, - routeArguments, - removeRouteArguments, - onRegisterGame, - showConfirmation, - showAlert, - project, - setOpenedGameId, - ] - ); - - const onBack = React.useCallback( - () => { - setCurrentTab('details'); - setOpenedGameId(null); - }, - [setCurrentTab, setOpenedGameId] - ); - - if (openedGame) { - return ( - - - - ); - } - - return ( - - - {!profile ? ( - - - - - - Log-in or create an account to access your{' '} - - Window.openExternalURL(publishingWikiArticle) - } - > - published games - {' '} - retention metrics, and player feedback. - - - -
- Login} - onClick={onOpenLoginDialog} - /> -
-
- Create an account} - onClick={onOpenCreateAccountDialog} - /> -
-
-
-
-
- ) : ( - - - {!isRegisteringGame && ( - - - - )} - {games ? ( - games.length === 0 ? ( - - - - - - - Learn how many users are playing your game, control - published versions, and collect feedback from play - testers. - - - - - - - - - Window.openExternalURL(publishingWikiArticle) - } - > - Share a project - {' '} - to get started. - - - - - - - ) : ( - - ) - ) : gamesFetchingError ? ( - - - Can't load the games. Verify your internet connection or retry - later. - - - ) : ( - - - - )} - - )} -
-
- ); -}; - -const ManageSectionWithErrorBoundary = (props: Props) => ( - Manage section} - scope="start-page-manage" - > - - -); - -export default ManageSectionWithErrorBoundary; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/TeamSection/TeamSection.spec.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/TeamSection/TeamSection.spec.js index 1a904c7c1a41..287b557e8574 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/TeamSection/TeamSection.spec.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/TeamSection/TeamSection.spec.js @@ -38,7 +38,7 @@ const getDefaultMembership = ({ createdAt: 16798698390, }); -const getDefaultGroup = ({ id }: { id: string }) => ({ +const getDefaultGroup = ({ id }: {| id: string |}) => ({ id, name: 'Group', }); diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/index.js index f0485d5a1c40..ebe961851086 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/index.js @@ -9,10 +9,9 @@ import { type StorageProvider, } from '../../../ProjectsStorage'; import GetStartedSection from './GetStartedSection'; -import BuildSection from './BuildSection'; import LearnSection from './LearnSection'; import PlaySection from './PlaySection'; -import ManageSection from './ManageSection'; +import CreateSection from './CreateSection'; import StoreSection from './StoreSection'; import { type TutorialCategory } from '../../../Utils/GDevelopServices/Tutorial'; import { TutorialContext } from '../../../Tutorial/TutorialContext'; @@ -52,10 +51,14 @@ const getRequestedTab = (routeArguments: RouteArguments): HomeTab | null => { routeArguments['initial-dialog'] === 'store' // New way of opening the store ) { return 'shop'; - } else if (routeArguments['initial-dialog'] === 'games-dashboard') { - return 'manage'; - } else if (routeArguments['initial-dialog'] === 'build') { - return 'build'; + } else if ( + [ + 'games-dashboard', + 'build', // Compatibility with old links + 'create', + ].includes(routeArguments['initial-dialog']) + ) { + return 'create'; } else if (routeArguments['initial-dialog'] === 'education') { return 'team-view'; } else if (routeArguments['initial-dialog'] === 'play') { @@ -258,7 +261,7 @@ export const HomePage = React.memo( ? tabRequestedAtOpening.current : showGetStartedSectionByDefault ? 'get-started' - : 'build'; + : 'create'; const [activeTab, setActiveTab] = React.useState(initialTab); @@ -356,7 +359,7 @@ export const HomePage = React.memo( // redirects to the games dashboard. React.useEffect( () => { - if ((activeTab === 'manage' || activeTab === 'build') && !games) { + if (activeTab === 'create' && !games) { fetchGames(); } }, @@ -374,10 +377,10 @@ export const HomePage = React.memo( ); // Refresh games list (as one could have been modified using the game dashboard - // in the project manager) when navigating to the "Manage" tab. + // in the project manager) when navigating to the "Create" tab. React.useEffect( () => { - if (isActive && activeTab === 'manage' && authenticated) { + if (isActive && activeTab === 'create' && authenticated) { fetchGames(); } }, @@ -467,28 +470,14 @@ export const HomePage = React.memo( [authenticated] ); - const onManageGame = React.useCallback((gameId: string) => { - setOpenedGameId(gameId); - setActiveTab('manage'); - }, []); - - const canManageGame = React.useCallback( - (gameId: string): boolean => { - if (!games) return false; - const matchingGameIndex = games.findIndex(game => game.id === gameId); - return matchingGameIndex > -1; - }, - [games] - ); - return ( {({ i18n }) => (
- {activeTab === 'manage' && ( - ( setOpenedGameId={setOpenedGameId} currentTab={gameDetailsCurrentTab} setCurrentTab={setGameDetailsCurrentTab} + canOpen={canOpen} + onOpenProfile={onOpenProfile} + askToCloseProject={askToCloseProject} + onCreateProjectFromExample={onCreateProjectFromExample} + onSelectExampleShortHeader={onSelectExampleShortHeader} + onSelectPrivateGameTemplateListingData={ + onSelectPrivateGameTemplateListingData + } + i18n={i18n} + onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog} + onChooseProject={onChooseProject} + onSaveProject={onSave} + canSaveProject={canSave} /> )} {activeTab === 'get-started' && ( @@ -517,25 +519,6 @@ export const HomePage = React.memo( askToCloseProject={askToCloseProject} /> )} - {activeTab === 'build' && ( - - )} {activeTab === 'learn' && ( void, setFetchPlayerTokenForPreviewAutomatically: (enabled: boolean) => void, setPreviewCrashReportUploadLevel: (level: string) => void, - setGamesListOrderBy: ( - orderBy: 'createdAt' | 'totalSessions' | 'weeklySessions' + setGamesDashboardOrderBy: ( + orderBy: 'lastModifiedAt' | 'totalSessions' | 'weeklySessions' ) => void, setTakeScreenshotOnPreview: (enabled: boolean) => void, |}; @@ -393,7 +394,7 @@ export const initialPreferences = { editorStateByProject: {}, fetchPlayerTokenForPreviewAutomatically: true, previewCrashReportUploadLevel: 'exclude-javascript-code-events', - gamesListOrderBy: 'createdAt', + gamesDashboardOrderBy: 'lastModifiedAt', takeScreenshotOnPreview: true, }, setLanguage: () => {}, @@ -465,8 +466,8 @@ export const initialPreferences = { setEditorStateForProject: (projectId, editorState) => {}, setFetchPlayerTokenForPreviewAutomatically: (enabled: boolean) => {}, setPreviewCrashReportUploadLevel: (level: string) => {}, - setGamesListOrderBy: ( - orderBy: 'createdAt' | 'totalSessions' | 'weeklySessions' + setGamesDashboardOrderBy: ( + orderBy: 'lastModifiedAt' | 'totalSessions' | 'weeklySessions' ) => {}, setTakeScreenshotOnPreview: (enabled: boolean) => {}, }; diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js index b6583ab83248..5e6c75ee3e09 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js @@ -26,6 +26,7 @@ import { setLanguageInDOM, selectLanguageOrLocale, } from '../../Utils/Language'; +import { type GamesDashboardOrderBy } from '../../GameDashboard/GamesList'; import { CHECK_APP_UPDATES_TIMEOUT } from '../../Utils/GlobalFetchTimeouts'; const electron = optionalRequire('electron'); const ipcRenderer = electron ? electron.ipcRenderer : null; @@ -196,7 +197,7 @@ export default class PreferencesProvider extends React.Component { setPreviewCrashReportUploadLevel: this._setPreviewCrashReportUploadLevel.bind( this ), - setGamesListOrderBy: this._setGamesListOrderBy.bind(this), + setGamesDashboardOrderBy: this._setGamesDashboardOrderBy.bind(this), setTakeScreenshotOnPreview: this._setTakeScreenshotOnPreview.bind(this), }; @@ -1008,14 +1009,12 @@ export default class PreferencesProvider extends React.Component { ); } - _setGamesListOrderBy( - newValue: 'createdAt' | 'totalSessions' | 'weeklySessions' - ) { + _setGamesDashboardOrderBy(newValue: GamesDashboardOrderBy) { this.setState( state => ({ values: { ...state.values, - gamesListOrderBy: newValue, + gamesDashboardOrderBy: newValue, }, }), () => this._persistValuesToLocalStorage(this.state) diff --git a/newIDE/app/src/MainFrame/RouterContext.js b/newIDE/app/src/MainFrame/RouterContext.js index 53b6c16b67ba..b2eaee277fbb 100644 --- a/newIDE/app/src/MainFrame/RouterContext.js +++ b/newIDE/app/src/MainFrame/RouterContext.js @@ -9,7 +9,8 @@ export type Route = | 'games-dashboard' | 'asset-store' // For compatibility when there was only asset packs. | 'store' // New way of opening the store. - | 'build' + | 'build' // Old way of opening the build section + | 'create' // New way of opening the build section | 'education' | 'play' | 'get-started'; diff --git a/newIDE/app/src/MainFrame/UseMultiplayerLobbyConfigurator.js b/newIDE/app/src/MainFrame/UseMultiplayerLobbyConfigurator.js index 77816b6ad186..e946c0c39c8a 100644 --- a/newIDE/app/src/MainFrame/UseMultiplayerLobbyConfigurator.js +++ b/newIDE/app/src/MainFrame/UseMultiplayerLobbyConfigurator.js @@ -3,7 +3,7 @@ import * as React from 'react'; import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; import { duplicateLobbyConfiguration } from '../Utils/GDevelopServices/Play'; import { registerGame } from '../Utils/GDevelopServices/Game'; -import { getDefaultRegisterGamePropertiesFromProject } from '../Utils/UseGameAndBuildsManager'; +import { getDefaultRegisterGameProperties } from '../Utils/UseGameAndBuildsManager'; const gd: libGDevelop = global.gd; @@ -46,7 +46,13 @@ export const useMultiplayerLobbyConfigurator = (): UseMultiplayerLobbyConfigurat await registerGame( getAuthorizationHeader, profile.id, - getDefaultRegisterGamePropertiesFromProject({ project }) + getDefaultRegisterGameProperties({ + projectId: project.getProjectUuid(), + projectName: project.getName(), + projectAuthor: project.getAuthor(), + // Assume the project is not saved at this stage. + savedStatus: 'draft', + }) ); } catch (error) { console.error( diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index d3291383568d..df965d1c3eb0 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -1135,12 +1135,27 @@ const MainFrame = (props: Props) => { setIsProjectOpening(true); }, getStorageProviderOperations, + getStorageProvider, afterCreatingProject: async ({ project, editorTabs, oldProjectId, options, }) => { + // Update the currentFileMetadata based on the updated project, as + // it can have been updated in the meantime (gameId, project name, etc...). + // Use the ref here to be sure to have the latest file metadata. + if (currentFileMetadataRef.current) { + const newFileMetadata: FileMetadata = { + ...currentFileMetadataRef.current, + name: project.getName(), + gameId: project.getProjectUuid(), + }; + setState(state => ({ + ...state, + currentFileMetadata: newFileMetadata, + })); + } setNewProjectSetupDialogOpen(false); if (options.openQuickCustomizationDialog) { setQuickCustomizationDialogOpenedFromGameId(oldProjectId); @@ -1792,7 +1807,7 @@ const MainFrame = (props: Props) => { { openEventsEditor, openSceneEditor, - }: { openEventsEditor: boolean, openSceneEditor: boolean } + }: {| openEventsEditor: boolean, openSceneEditor: boolean |} ): EditorTabsState => { const sceneEditorOptions = getEditorOpeningOptions({ kind: 'layout', @@ -1820,7 +1835,7 @@ const MainFrame = (props: Props) => { { openEventsEditor = true, openSceneEditor = true, - }: { openEventsEditor: boolean, openSceneEditor: boolean } = {}, + }: {| openEventsEditor: boolean, openSceneEditor: boolean |} = {}, editorTabs?: EditorTabsState ): void => { setState(state => ({ @@ -2647,6 +2662,10 @@ const MainFrame = (props: Props) => { return; } + if (fileMetadata.gameId) { + await gamesList.markGameAsSavedIfRelevant(fileMetadata.gameId); + } + // Save was done on a new file/location, so save it in the // recent projects and in the state. const fileMetadataAndStorageProviderName = { @@ -2718,6 +2737,7 @@ const MainFrame = (props: Props) => { currentlyRunningInAppTutorial, showAlert, showConfirmation, + gamesList, ] ); @@ -2836,6 +2856,12 @@ const MainFrame = (props: Props) => { console.info( `Project saved in ${performance.now() - saveStartTime}ms.` ); + // If project was saved, and a game is registered, ensure the game is + // marked as saved. + if (fileMetadata.gameId) { + await gamesList.markGameAsSavedIfRelevant(fileMetadata.gameId); + } + setCloudProjectSaveChoiceOpen(false); setCloudProjectRecoveryOpenedVersionId(null); @@ -2913,6 +2939,7 @@ const MainFrame = (props: Props) => { showAlert, showConfirmation, checkedOutVersionStatus, + gamesList, ] ); diff --git a/newIDE/app/src/MarketingPlans/MarketingPlanFeatures.js b/newIDE/app/src/MarketingPlans/MarketingPlanFeatures.js index cf6263769bb0..30e5f932141a 100644 --- a/newIDE/app/src/MarketingPlans/MarketingPlanFeatures.js +++ b/newIDE/app/src/MarketingPlans/MarketingPlanFeatures.js @@ -82,11 +82,9 @@ const MarketingPlanFeatures = ({ [gameFeaturings] ); - const planCreditsAmount = getMarketingPlanPrice(marketingPlan, limits); - if (!planCreditsAmount) { - console.error(`Could not find price for marketing plan ${id}, hiding it.`); - return null; - } + const planCreditsAmount = limits + ? getMarketingPlanPrice(marketingPlan, limits) + : null; const hasErrors = requirementsErrors.length > 0; const bulletPointsToDisplay = isPlanActive @@ -126,9 +124,11 @@ const MarketingPlanFeatures = ({ {selectMessageByLocale(i18n, nameByLocale)} - - {planCreditsAmount} credits - + {planCreditsAmount && ( + + {planCreditsAmount} credits + + )}
diff --git a/newIDE/app/src/MarketingPlans/MarketingPlans.js b/newIDE/app/src/MarketingPlans/MarketingPlans.js index a3f868441df6..61c5d4751b1d 100644 --- a/newIDE/app/src/MarketingPlans/MarketingPlans.js +++ b/newIDE/app/src/MarketingPlans/MarketingPlans.js @@ -24,11 +24,11 @@ import MarketingPlanFeatures from './MarketingPlanFeatures'; import usePurchaseMarketingPlan from './UsePurchaseMarketingPlan'; type Props = {| - game: Game, + game?: Game, |}; const MarketingPlans = ({ game }: Props) => { - const { profile, limits, getAuthorizationHeader } = React.useContext( + const { profile, getAuthorizationHeader } = React.useContext( AuthenticatedUserContext ); const { @@ -63,7 +63,11 @@ const MarketingPlans = ({ game }: Props) => { const fetchGameFeaturings = React.useCallback( async () => { - if (!profile) return; + if (!profile || !game) { + setGameFeaturings([]); + return; + } + try { setGameFeaturingsError(null); const gameFeaturings = await listGameFeaturings( @@ -98,8 +102,6 @@ const MarketingPlans = ({ game }: Props) => { fetchGameFeaturings, }); - if (!profile || !limits) return null; - return ( {({ i18n }) => @@ -143,9 +145,10 @@ const MarketingPlans = ({ game }: Props) => { activeGameFeaturings ); - const requirementsErrors = isPlanActive - ? getRequirementsErrors(game, marketingPlan) - : []; + const requirementsErrors = + isPlanActive && game + ? getRequirementsErrors(game, marketingPlan) + : []; return ( Promise, |}; @@ -39,7 +39,13 @@ const usePurchaseMarketingPlan = ({ const onPurchase = React.useCallback( async (i18n: I18nType, marketingPlan: MarketingPlan) => { - if (!profile || !limits) return; + if (!game || !profile || !limits) { + await showAlert({ + title: t`Select a game`, + message: t`In order to purchase a marketing boost, log-in and select a game in your dashboard.`, + }); + return; + } const { id, diff --git a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/SpritesList.js b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/SpritesList.js index d632126aa4a3..ef6b7b0122f3 100644 --- a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/SpritesList.js +++ b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/SpritesList.js @@ -341,7 +341,7 @@ const SpritesList = ({ ); const onSortEnd = React.useCallback( - ({ oldIndex, newIndex }: { oldIndex: number, newIndex: number }) => { + ({ oldIndex, newIndex }: {| oldIndex: number, newIndex: number |}) => { if (oldIndex === newIndex) return; // We store the selection value of the moved sprite, as its pointer will // be changed by the move. diff --git a/newIDE/app/src/Profile/AuthenticatedUserProvider.js b/newIDE/app/src/Profile/AuthenticatedUserProvider.js index 7e1e26e7fe88..f8fc2425b91c 100644 --- a/newIDE/app/src/Profile/AuthenticatedUserProvider.js +++ b/newIDE/app/src/Profile/AuthenticatedUserProvider.js @@ -1322,7 +1322,7 @@ export default class AuthenticatedUserProvider extends React.Component< }); }; - showUserSnackbar = ({ message }: { message: ?React.Node }) => { + showUserSnackbar = ({ message }: {| message: ?React.Node |}) => { this.setState({ // The message is wrapped here to prevent crashes when Google Translate // translates the website. See https://github.com/4ian/GDevelop/issues/3453. diff --git a/newIDE/app/src/ProjectCreation/NewProjectSetupDialog.js b/newIDE/app/src/ProjectCreation/NewProjectSetupDialog.js index 45dc0cd5b95c..95ba03c2e6db 100644 --- a/newIDE/app/src/ProjectCreation/NewProjectSetupDialog.js +++ b/newIDE/app/src/ProjectCreation/NewProjectSetupDialog.js @@ -512,7 +512,6 @@ const NewProjectSetupDialog = ({ }} i18n={i18n} columnsCount={getItemsColumns(windowSize, isLandscape)} - onlyShowGames rowToInsert={{ row: 2, element: ( @@ -531,6 +530,7 @@ const NewProjectSetupDialog = ({ /> ), }} + hideStartingPoints /> )} diff --git a/newIDE/app/src/ProjectsStorage/CloudStorageProvider/CloudProjectWriter.js b/newIDE/app/src/ProjectsStorage/CloudStorageProvider/CloudProjectWriter.js index e93bbf8eb269..104eb4e99d48 100644 --- a/newIDE/app/src/ProjectsStorage/CloudStorageProvider/CloudProjectWriter.js +++ b/newIDE/app/src/ProjectsStorage/CloudStorageProvider/CloudProjectWriter.js @@ -236,13 +236,15 @@ export const generateOnSaveProjectAs = ( ) => { if (!saveAsLocation) throw new Error('A location was not chosen before saving as.'); - const { name, gameId } = saveAsLocation; + const { name } = saveAsLocation; if (!name) throw new Error('A name was not chosen before saving as.'); if (!authenticatedUser.authenticated) { return { wasSaved: false, fileMetadata: null }; } options.onStartSaving(); + const gameId = saveAsLocation.gameId || project.getProjectUuid(); + try { // Create a new cloud project. const cloudProject = await createCloudProject(authenticatedUser, { diff --git a/newIDE/app/src/QuickCustomization/QuickCustomizationGameTiles.js b/newIDE/app/src/QuickCustomization/QuickCustomizationGameTiles.js index 96e5653214c3..55c9dd0cc8e3 100644 --- a/newIDE/app/src/QuickCustomization/QuickCustomizationGameTiles.js +++ b/newIDE/app/src/QuickCustomization/QuickCustomizationGameTiles.js @@ -9,13 +9,6 @@ import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasur import { type QuickCustomizationRecommendation } from '../Utils/GDevelopServices/User'; import { selectMessageByLocale } from '../Utils/i18n/MessageByLocale'; -type Props = {| - onSelectExampleShortHeader: ( - exampleShortHeader: ExampleShortHeader - ) => Promise, - quickCustomizationRecommendation: QuickCustomizationRecommendation, -|}; - const styles = { grid: { margin: 0, @@ -25,16 +18,36 @@ const styles = { cellSpacing: 2, }; +const getColumnsCount = (windowSize: string, isLandscape: boolean) => { + if (windowSize === 'small') { + return isLandscape ? 3 : 2; + } else if (windowSize === 'medium') { + return 3; + } else if (windowSize === 'large') { + return 4; + } else { + return 6; + } +}; + +type Props = {| + onSelectExampleShortHeader: ( + exampleShortHeader: ExampleShortHeader + ) => Promise, + quickCustomizationRecommendation: QuickCustomizationRecommendation, +|}; + export const QuickCustomizationGameTiles = ({ onSelectExampleShortHeader, quickCustomizationRecommendation, }: Props) => { const { exampleShortHeaders } = React.useContext(ExampleStoreContext); const { windowSize, isLandscape } = useResponsiveWindowSize(); + const columnsCount = getColumnsCount(windowSize, isLandscape); const displayedExampleShortHeaders = React.useMemo( - () => - exampleShortHeaders + () => { + const allQuickCustomizationExampleShortHeaders = exampleShortHeaders ? quickCustomizationRecommendation.list .map(({ type, exampleSlug, thumbnailTitleByLocale }) => { if (type !== 'example') { @@ -52,7 +65,10 @@ export const QuickCustomizationGameTiles = ({ }; }) .filter(Boolean) - : null, + : null; + + return allQuickCustomizationExampleShortHeaders; + }, [exampleShortHeaders, quickCustomizationRecommendation.list] ); @@ -60,15 +76,7 @@ export const QuickCustomizationGameTiles = ({ {({ i18n }) => ( { + async ({ build }: {| build: ?Build |}) => { try { if (profile && game && build) { setExportState('updating-game'); @@ -148,6 +148,9 @@ export const QuickPublish = ({ { publicWebBuildId: build.id, screenshotUrls: newGameScreenshotUrls, + // Here we assume the game is saved, as it just got exported properly, + // And the same is happening in the background. + savedStatus: 'saved', } ); setGame(updatedGame); diff --git a/newIDE/app/src/UI/Alert/AlertDialog.js b/newIDE/app/src/UI/Alert/AlertDialog.js index ca2f80fc537f..590ac0292c1a 100644 --- a/newIDE/app/src/UI/Alert/AlertDialog.js +++ b/newIDE/app/src/UI/Alert/AlertDialog.js @@ -7,6 +7,7 @@ import { type MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow' import Dialog from '../Dialog'; import FlatButton from '../FlatButton'; import Text from '../Text'; +import { MarkdownText } from '../MarkdownText'; type Props = {| open: boolean, @@ -42,7 +43,9 @@ function AlertDialog(props: Props) { onRequestClose={props.onDismiss} onApply={props.onDismiss} > - {i18n._(props.message)} + + + )} diff --git a/newIDE/app/src/UI/BackgroundText.js b/newIDE/app/src/UI/BackgroundText.js index 10e1e2e09de7..5c0b8d4edc1c 100644 --- a/newIDE/app/src/UI/BackgroundText.js +++ b/newIDE/app/src/UI/BackgroundText.js @@ -9,6 +9,7 @@ type Props = {| style?: Object, children: ?React.Node, allowSelection?: boolean, + align?: 'center' | 'left' | 'right', |}; const BackgroundText = (props: Props) => { @@ -16,7 +17,7 @@ const BackgroundText = (props: Props) => { return ( ( + + + + + + +)); diff --git a/newIDE/app/src/UI/CustomSvgIcons/WarningRound.js b/newIDE/app/src/UI/CustomSvgIcons/WarningRound.js new file mode 100644 index 000000000000..8ffadeced5c7 --- /dev/null +++ b/newIDE/app/src/UI/CustomSvgIcons/WarningRound.js @@ -0,0 +1,31 @@ +import React from 'react'; +import SvgIcon from '@material-ui/core/SvgIcon'; + +export default React.memo( + React.forwardRef((props, ref) => ( + + + + + + )) +); diff --git a/newIDE/app/src/UI/Dialog.js b/newIDE/app/src/UI/Dialog.js index c559c7e8fd69..8f4e67ae7892 100644 --- a/newIDE/app/src/UI/Dialog.js +++ b/newIDE/app/src/UI/Dialog.js @@ -148,7 +148,11 @@ const useDangerousStylesForDialog = (dangerLevel?: 'warning' | 'danger') => // Customize scrollbar inside Dialog so that it gives a bit of space // to the content. -const useStylesForDialogContent = ({ forceScroll }: { forceScroll: boolean }) => +const useStylesForDialogContent = ({ + forceScroll, +}: {| + forceScroll: boolean, +|}) => makeStyles({ root: { ...(forceScroll ? { overflowY: 'scroll' } : {}), // Force a scrollbar to prevent layout shifts. diff --git a/newIDE/app/src/UI/ErrorBoundary.js b/newIDE/app/src/UI/ErrorBoundary.js index e7396ffe53f1..eb48a0773559 100644 --- a/newIDE/app/src/UI/ErrorBoundary.js +++ b/newIDE/app/src/UI/ErrorBoundary.js @@ -35,12 +35,11 @@ type ErrorBoundaryScope = | 'editor' | 'start-page' | 'start-page-get-started' - | 'start-page-build' | 'start-page-shop' | 'start-page-learn' | 'start-page-play' | 'start-page-team' - | 'start-page-manage' + | 'start-page-create' | 'about' | 'preferences' | 'profile' diff --git a/newIDE/app/src/UI/GravatarUrl.js b/newIDE/app/src/UI/GravatarUrl.js index 69f947b3fbad..fb9525a06117 100644 --- a/newIDE/app/src/UI/GravatarUrl.js +++ b/newIDE/app/src/UI/GravatarUrl.js @@ -3,7 +3,7 @@ import md5 from 'blueimp-md5'; export const getGravatarUrl = ( email: string, - { size }: { size: number } = { size: 40 } + { size }: {| size: number |} = { size: 40 } ): string => { const hash = md5(email.trim().toLowerCase()); return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=retro`; diff --git a/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js b/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js index c421482011ad..09d6fe3a8b69 100644 --- a/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js +++ b/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js @@ -14,7 +14,7 @@ type Props = {| primary: true, // Force making only primary raised split buttons. disabled?: boolean, icon?: React.Node, - onClick: ?() => void, + onClick: ?() => void | Promise, buildMenuTemplate: (i18n: I18nType) => Array, style?: {| marginTop?: number, @@ -24,6 +24,7 @@ type Props = {| margin?: number, flexShrink?: 0, |}, + fullWidth?: boolean, |}; const shouldNeverBeCalled = () => { @@ -40,6 +41,8 @@ const styles = { minWidth: 30, paddingLeft: 0, paddingRight: 0, + // Make the button shrink to its minimum size. + flexBasis: 0, }, }; @@ -48,7 +51,15 @@ const styles = { * when the dropdown arrow is clicked. */ const RaisedButtonWithSplitMenu = (props: Props) => { - const { id, buildMenuTemplate, onClick, label, icon, disabled } = props; + const { + id, + buildMenuTemplate, + onClick, + label, + icon, + disabled, + fullWidth, + } = props; // In theory, focus ripple is only shown after a keyboard interaction // (see https://github.com/mui-org/material-ui/issues/12067). However, as @@ -64,6 +75,7 @@ const RaisedButtonWithSplitMenu = (props: Props) => { disabled={disabled} size="small" style={props.style} + fullWidth={fullWidth} > ); } diff --git a/newIDE/app/src/Utils/GDevelopServices/Build.js b/newIDE/app/src/Utils/GDevelopServices/Build.js index 540dd3d72796..a484e83fca70 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Build.js +++ b/newIDE/app/src/Utils/GDevelopServices/Build.js @@ -361,7 +361,7 @@ export const updateBuild = ( getAuthorizationHeader: () => Promise, userId: string, buildId: string, - { name, description }: { name?: string, description?: string } + { name, description }: {| name?: string, description?: string |} ): Promise => { return getAuthorizationHeader() .then(authorizationHeader => diff --git a/newIDE/app/src/Utils/GDevelopServices/Game.js b/newIDE/app/src/Utils/GDevelopServices/Game.js index 8549eafb5d75..2fcd7169e427 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Game.js +++ b/newIDE/app/src/Utils/GDevelopServices/Game.js @@ -10,6 +10,8 @@ import { t } from '@lingui/macro'; export type GameUploadType = 'game-thumbnail' | 'game-screenshot'; +export type SavedStatus = 'draft' | 'saved'; + export type CachedGameSlug = { username: string, gameSlug: string, @@ -52,6 +54,7 @@ export type Game = {| categories?: string[], authorName: string, // this corresponds to the publisher name createdAt: number, + updatedAt?: number, // Some old games don't have this field publicWebBuildId?: ?string, description?: string, thumbnailUrl?: string, @@ -68,6 +71,7 @@ export type Game = {| playWithKeyboard: boolean, playWithMobile: boolean, playWithGamepad: boolean, + savedStatus?: SavedStatus, |}; export type GameUpdatePayload = {| @@ -86,6 +90,7 @@ export type GameUpdatePayload = {| acceptsBuildComments?: boolean, acceptsGameComments?: boolean, displayAdsOnGamePage?: boolean, + savedStatus?: SavedStatus, |}; export type GameCategory = { @@ -290,11 +295,13 @@ export const registerGame = async ( gameName, authorName, templateSlug, + savedStatus, }: {| gameId: string, gameName: string, authorName: string, - templateSlug: string, + templateSlug?: string, + savedStatus?: SavedStatus, |} ): Promise => { const authorizationHeader = await getAuthorizationHeader(); @@ -305,6 +312,7 @@ export const registerGame = async ( gameName, authorName, templateSlug, + savedStatus, }, { params: { @@ -339,6 +347,7 @@ export const updateGame = async ( acceptsBuildComments, acceptsGameComments, displayAdsOnGamePage, + savedStatus, }: GameUpdatePayload ): Promise => { const authorizationHeader = await getAuthorizationHeader(); @@ -360,6 +369,7 @@ export const updateGame = async ( acceptsBuildComments, acceptsGameComments, displayAdsOnGamePage, + savedStatus, }, { params: { diff --git a/newIDE/app/src/Utils/GDevelopServices/Project.js b/newIDE/app/src/Utils/GDevelopServices/Project.js index 7818cecf50e6..b47acc72b777 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Project.js +++ b/newIDE/app/src/Utils/GDevelopServices/Project.js @@ -261,7 +261,7 @@ export const clearCloudProjectCredentials = async (): Promise => { export const createCloudProject = async ( authenticatedUser: AuthenticatedUser, - cloudProjectCreationPayload: {| name: string, gameId?: string |} + cloudProjectCreationPayload: {| name: string, gameId: string |} ): Promise => { const { getAuthorizationHeader, firebaseUser } = authenticatedUser; if (!firebaseUser) return null; @@ -705,7 +705,7 @@ export const deleteProjectUserAcl = async ( export const listProjectUserAcls = async ( authenticatedUser: AuthenticatedUser, - { projectId }: { projectId: string } + { projectId }: {| projectId: string |} ): Promise> => { const { getAuthorizationHeader, firebaseUser } = authenticatedUser; if (!firebaseUser) return []; diff --git a/newIDE/app/src/Utils/UseCreateProject.js b/newIDE/app/src/Utils/UseCreateProject.js index 1a9a21f2db9c..16cedd3e51b4 100644 --- a/newIDE/app/src/Utils/UseCreateProject.js +++ b/newIDE/app/src/Utils/UseCreateProject.js @@ -35,7 +35,7 @@ import { createPrivateGameTemplateUrl, type CourseChapter, } from './GDevelopServices/Asset'; -import { getDefaultRegisterGamePropertiesFromProject } from './UseGameAndBuildsManager'; +import { getDefaultRegisterGameProperties } from './UseGameAndBuildsManager'; import { TutorialContext } from '../Tutorial/TutorialContext'; type Props = {| @@ -51,6 +51,7 @@ type Props = {| getStorageProviderOperations: ( storageProvider?: ?StorageProvider ) => StorageProviderOperations, + getStorageProvider: () => StorageProvider, loadFromProject: ( project: gdProject, fileMetadata: ?FileMetadata @@ -72,6 +73,7 @@ const useCreateProject = ({ onSuccessOrError, onError, getStorageProviderOperations, + getStorageProvider, loadFromProject, openFromFileMetadata, onProjectSaved, @@ -164,8 +166,17 @@ const useCreateProject = ({ await registerGame( authenticatedUser.getAuthorizationHeader, authenticatedUser.profile.id, - getDefaultRegisterGamePropertiesFromProject({ - project: currentProject, + getDefaultRegisterGameProperties({ + projectId: currentProject.getProjectUuid(), + projectName: currentProject.getName(), + projectAuthor: currentProject.getAuthor(), + // Project is saved if choosing cloud or local storage provider. + savedStatus: + newProjectSetup.storageProvider.internalName === + 'LocalFile' || + newProjectSetup.storageProvider.internalName === 'Cloud' + ? 'saved' + : 'draft', }) ); await onGameRegistered(); @@ -181,6 +192,8 @@ const useCreateProject = ({ const destinationStorageProviderOperations = getStorageProviderOperations( newProjectSetup.storageProvider ); + const newStorageProvider = getStorageProvider(); + const storageProviderInternalName = newStorageProvider.internalName; const { onSaveProjectAs } = destinationStorageProviderOperations; @@ -219,13 +232,29 @@ const useCreateProject = ({ } ); - if (wasSaved) { - onProjectSaved(fileMetadata); - unsavedChanges.sealUnsavedChanges({ setCheckpointTime: true }); - if (newProjectSetup.storageProvider.internalName === 'LocalFile') { - preferences.setHasProjectOpened(true); - } + if (!wasSaved) { + return; // Saving was cancelled. + } + + if (!fileMetadata) { + return; } + + onProjectSaved(fileMetadata); + unsavedChanges.sealUnsavedChanges({ setCheckpointTime: true }); + if (newProjectSetup.storageProvider.internalName === 'LocalFile') { + preferences.setHasProjectOpened(true); + } + + // Save was done on a new file/location, so save it in the + // recent projects and in the state. + const fileMetadataAndStorageProviderName = { + fileMetadata, + storageProviderName: storageProviderInternalName, + }; + preferences.insertRecentProjectFile( + fileMetadataAndStorageProviderName + ); } // We were able to load and then save the project. We can now close the dialog, @@ -256,6 +285,7 @@ const useCreateProject = ({ }, [ authenticatedUser, + getStorageProvider, getStorageProviderOperations, loadFromProject, onError, diff --git a/newIDE/app/src/Utils/UseDisplayNewFeature.js b/newIDE/app/src/Utils/UseDisplayNewFeature.js index 8e807f68ac1e..ba1564f4cf99 100644 --- a/newIDE/app/src/Utils/UseDisplayNewFeature.js +++ b/newIDE/app/src/Utils/UseDisplayNewFeature.js @@ -25,7 +25,7 @@ const useDisplayNewFeature = () => { } = React.useContext(PreferencesContext); const shouldDisplayNewFeatureHighlighting = React.useCallback( - ({ featureId }: { featureId: Feature }): boolean => { + ({ featureId }: {| featureId: Feature |}): boolean => { const programOpeningCount = getProgramOpeningCount(); const settings = featuresDisplaySettings[featureId]; if (!settings) return false; @@ -50,7 +50,7 @@ const useDisplayNewFeature = () => { ); const acknowledgeNewFeature = React.useCallback( - ({ featureId }: { featureId: Feature }) => { + ({ featureId }: {| featureId: Feature |}) => { if (!featuresDisplaySettings[featureId]) return; const acknowledgments = newFeaturesAcknowledgements[featureId]; diff --git a/newIDE/app/src/Utils/UseGameAndBuildsManager.js b/newIDE/app/src/Utils/UseGameAndBuildsManager.js index 9dafb2f965a6..1edbf166b9ff 100644 --- a/newIDE/app/src/Utils/UseGameAndBuildsManager.js +++ b/newIDE/app/src/Utils/UseGameAndBuildsManager.js @@ -11,6 +11,7 @@ import { registerGame, setGameUserAcls, type Game, + type SavedStatus, } from './GDevelopServices/Game'; import { extractGDevelopApiErrorStatusAndCode } from './GDevelopServices/Errors'; import { useMultiplayerLobbyConfigurator } from '../MainFrame/UseMultiplayerLobbyConfigurator'; @@ -19,17 +20,21 @@ import { replaceLeaderboardsInProject, } from '../Leaderboard/UseLeaderboardReplacer'; -export const getDefaultRegisterGamePropertiesFromProject = ({ - project, - isRemix, +export const getDefaultRegisterGameProperties = ({ + projectId, + projectName, + projectAuthor, + savedStatus, }: {| - project: gdProject, - isRemix?: boolean, + projectId: string, + projectName: ?string, + projectAuthor: ?string, + savedStatus: SavedStatus, |}) => ({ - gameId: project.getProjectUuid(), - authorName: project.getAuthor() || 'Unspecified publisher', - gameName: project.getName() + (isRemix ? ' Remix' : '') || 'Untitled game', - templateSlug: project.getTemplateSlug(), + gameId: projectId, + authorName: projectAuthor || 'Unspecified publisher', + gameName: projectName || 'Untitled game', + savedStatus, }); export type GameManager = {| @@ -153,7 +158,14 @@ export const useGameManager = ({ await registerGame( getAuthorizationHeader, userId, - getDefaultRegisterGamePropertiesFromProject({ project }) + getDefaultRegisterGameProperties({ + projectId: gameId, + projectName: project.getName(), + projectAuthor: project.getAuthor(), + // Assume a project going through the export process is not saved yet. + // It will be marked as saved when the user saves it next anyway. + savedStatus: 'draft', + }) ); // We don't await for the authors update, as it is not required for publishing. diff --git a/newIDE/app/src/fixtures/GDevelopServicesTestData/index.js b/newIDE/app/src/fixtures/GDevelopServicesTestData/index.js index 95137c2fb7f8..501665317174 100644 --- a/newIDE/app/src/fixtures/GDevelopServicesTestData/index.js +++ b/newIDE/app/src/fixtures/GDevelopServicesTestData/index.js @@ -39,6 +39,7 @@ import { } from '../../Utils/GDevelopServices/Announcement'; import { type PrivateGameTemplateListingData } from '../../Utils/GDevelopServices/Shop'; import { fakeAchievements } from './FakeAchievements'; +import { type FileMetadataAndStorageProviderName } from '../../ProjectsStorage'; export const indieFirebaseUser: FirebaseUser = { uid: 'indie-user', @@ -3711,6 +3712,7 @@ export const fakeGame: Game = { authorName: 'SonicFan', gameName: 'Sonic1995', createdAt: 1606065498, + updatedAt: 1606065498, publicWebBuildId: 'fake-public-web-build-id-sonic', displayAdsOnGamePage: true, discoverable: true, @@ -3725,6 +3727,7 @@ export const game1: Game = { authorName: 'My company', gameName: 'My Great Game', createdAt: 1606065498, + updatedAt: 1606065498, publicWebBuildId: 'fake-publicwebbuild-id', displayAdsOnGamePage: true, orientation: 'default', @@ -3738,6 +3741,7 @@ export const game2: Game = { authorName: 'My company', gameName: 'My Other Game', createdAt: 1607065498, + updatedAt: 1606065498, playWithKeyboard: true, playWithMobile: false, playWithGamepad: false, @@ -3783,6 +3787,26 @@ export const gameWithDisplayAdsOnGamePageDisabled: Game = { displayAdsOnGamePage: false, }; +export const fakeFileMetadataAndStorageProviderNameForLocalProject: FileMetadataAndStorageProviderName = { + fileMetadata: { + fileIdentifier: 'localProject', + name: 'Local project', + lastModifiedDate: Date.now(), + gameId: 'localProject', + }, + storageProviderName: 'LocalFile', +}; + +export const fakeFileMetadataAndStorageProviderNameForCloudProject: FileMetadataAndStorageProviderName = { + fileMetadata: { + fileIdentifier: 'cloudProject', + name: 'Cloud project', + lastModifiedDate: Date.now(), + gameId: 'cloudProject', + }, + storageProviderName: 'Cloud', +}; + export const allGameCategoriesMocked = [ { name: 'action', diff --git a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetDetails.stories.js b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetDetails.stories.js index c255494de15f..b88cfede07ab 100644 --- a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetDetails.stories.js +++ b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetDetails.stories.js @@ -18,7 +18,7 @@ export default { decorators: [paperDecorator], }; -const Wrapper = ({ children }: { children: React.Node }) => { +const Wrapper = ({ children }: {| children: React.Node |}) => { const navigationState = useShopNavigation(); return ( diff --git a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetPackInstallDialog.stories.js b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetPackInstallDialog.stories.js index 774a239f995f..7dc5aa3b94b7 100644 --- a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetPackInstallDialog.stories.js +++ b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetPackInstallDialog.stories.js @@ -75,7 +75,7 @@ const mockFailedApiDataForPublicAsset1 = [ }, ]; -const Wrapper = ({ children }: { children: React.Node }) => { +const Wrapper = ({ children }: {| children: React.Node |}) => { const navigationState = useShopNavigation(); return ( { +const Wrapper = ({ children }: {| children: React.Node |}) => { const navigationState = useShopNavigation(); return ( diff --git a/newIDE/app/src/stories/componentStories/AssetStore/CustomObjectPackResults.stories.js b/newIDE/app/src/stories/componentStories/AssetStore/CustomObjectPackResults.stories.js index d4b1b44eb0fc..986eab1c59b3 100644 --- a/newIDE/app/src/stories/componentStories/AssetStore/CustomObjectPackResults.stories.js +++ b/newIDE/app/src/stories/componentStories/AssetStore/CustomObjectPackResults.stories.js @@ -14,7 +14,7 @@ export default { decorators: [paperDecorator], }; -const Wrapper = ({ children }: { children: React.Node }) => { +const Wrapper = ({ children }: {| children: React.Node |}) => { const navigationState = useShopNavigation(); return ( diff --git a/newIDE/app/src/stories/componentStories/AssetStore/ResourceStore/ResourceStore.stories.js b/newIDE/app/src/stories/componentStories/AssetStore/ResourceStore/ResourceStore.stories.js index b44dee9942a0..600c4937428d 100644 --- a/newIDE/app/src/stories/componentStories/AssetStore/ResourceStore/ResourceStore.stories.js +++ b/newIDE/app/src/stories/componentStories/AssetStore/ResourceStore/ResourceStore.stories.js @@ -15,7 +15,7 @@ export default { decorators: [getPaperDecorator('medium')], }; -const ResourceStoreStory = ({ kind }: { kind: 'audio' | 'font' | 'svg' }) => { +const ResourceStoreStory = ({ kind }: {| kind: 'audio' | 'font' | 'svg' |}) => { const [ selectedResourceIndex, setSelectedResourceIndex, diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GameCard.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GameCard.stories.js deleted file mode 100644 index 5429fdaca43a..000000000000 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GameCard.stories.js +++ /dev/null @@ -1,43 +0,0 @@ -// @flow - -import * as React from 'react'; -import { action } from '@storybook/addon-actions'; - -import paperDecorator from '../../PaperDecorator'; -import { GameCard } from '../../../GameDashboard/GameCard'; -import { - fakeSilverAuthenticatedUser, - game1, -} from '../../../fixtures/GDevelopServicesTestData'; -import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; -import CloudStorageProvider from '../../../ProjectsStorage/CloudStorageProvider'; - -export default { - title: 'GameDashboard/GameCard', - component: GameCard, - decorators: [paperDecorator], -}; - -export const DefaultGameCard = () => ( - - - -); - -export const DefaultCurrentlyEditedCard = () => ( - - - -); diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js index 152c8d2a1457..865a105e83e3 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js @@ -161,10 +161,6 @@ export const Default = ({ exports: 'None' | 'Some ongoing' | 'All complete', |}) => { const [game, setGame] = React.useState(game1); - const [ - gameUnregisterErrorText, - setGameUnregisterErrorText, - ] = React.useState(null); const [tab, setTab] = React.useState('details'); const [renderCount, setRenderCount] = React.useState(0); const feedbacksToDisplay = @@ -332,15 +328,10 @@ export const Default = ({ setCurrentView={setTab} onBack={() => action('Back')} onGameUpdated={() => action('onGameUpdated')} - onUnregisterGame={async () => - setGameUnregisterErrorText( - gameUnregisterErrorText - ? null - : 'You cannot unregister a game in a story' - ) - } - gameUnregisterErrorText={gameUnregisterErrorText} + onUnregisterGame={() => action('onUnregisterGame')} closeProject={action('closeProject')} + disabled={false} + onDeleteCloudProject={action('onDeleteCloudProject')} /> diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboardCard.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboardCard.stories.js new file mode 100644 index 000000000000..68dea99f25cf --- /dev/null +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboardCard.stories.js @@ -0,0 +1,298 @@ +// @flow + +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; + +import paperDecorator from '../../PaperDecorator'; +import GameDashboardCard from '../../../GameDashboard/GameDashboardCard'; +import { + fakeSilverAuthenticatedUser, + game1, + game2, + fakeFileMetadataAndStorageProviderNameForCloudProject, + fakeFileMetadataAndStorageProviderNameForLocalProject, +} from '../../../fixtures/GDevelopServicesTestData'; +import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; +import CloudStorageProvider from '../../../ProjectsStorage/CloudStorageProvider'; +import LocalFileStorageProvider from '../../../ProjectsStorage/LocalFileStorageProvider'; + +export default { + title: 'GameDashboard/GameDashboardCard', + component: GameDashboardCard, + decorators: [paperDecorator], +}; + +export const UnpublishedGame = () => ( + + + +); + +export const PublishedGame = () => ( + + + +); + +export const CurrentlyOpenedGame = () => ( + + + +); + +export const SavingGame = () => ( + + + +); + +export const DisabledGame = () => ( + + + +); + +export const LocalProject = () => ( + + + +); + +export const OpenedLocalProject = () => ( + + + +); + +export const DisabledLocalProject = () => ( + + + +); + +export const CloudProject = () => ( + + + +); + +export const OpenedCloudProject = () => ( + + + +); + +export const DisabledCloudProject = () => ( + + + +); diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js index 011a9cf14c58..6ac086e6ba8f 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js @@ -6,6 +6,8 @@ import paperDecorator from '../../PaperDecorator'; import { action } from '@storybook/addon-actions'; import { + fakeFileMetadataAndStorageProviderNameForCloudProject, + fakeFileMetadataAndStorageProviderNameForLocalProject, fakeSilverAuthenticatedUser, game1, game2, @@ -13,6 +15,11 @@ import { import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; import GamesList from '../../../GameDashboard/GamesList'; import CloudStorageProvider from '../../../ProjectsStorage/CloudStorageProvider'; +import PreferencesContext, { + initialPreferences, + type Preferences, +} from '../../../MainFrame/Preferences/PreferencesContext'; +import LocalFileStorageProvider from '../../../ProjectsStorage/LocalFileStorageProvider'; export default { title: 'GameDashboard/GamesList', @@ -20,17 +27,211 @@ export default { decorators: [paperDecorator], }; -export const WithoutAProjectOpened = () => { +export const NoGamesOrProjects = () => { + const projectFiles = []; + + const preferences: Preferences = { + ...initialPreferences, + values: { + ...initialPreferences.values, + recentProjectFiles: projectFiles, + }, + getRecentProjectFiles: () => projectFiles, + }; + + const [currentPage, setCurrentPage] = React.useState(1); + const [searchText, setSearchText] = React.useState(''); + + return ( + + + + + + ); +}; + +export const WithOnlyGames = () => { + const projectFiles = [ + { + ...fakeFileMetadataAndStorageProviderNameForLocalProject, + fileMetadata: { + ...fakeFileMetadataAndStorageProviderNameForLocalProject.fileMetadata, + gameId: game1.id, + }, + }, + ]; + + const preferences: Preferences = { + ...initialPreferences, + values: { + ...initialPreferences.values, + recentProjectFiles: projectFiles, + }, + getRecentProjectFiles: () => projectFiles, + }; + + const [currentPage, setCurrentPage] = React.useState(1); + const [searchText, setSearchText] = React.useState(''); + + return ( + + + + + + ); +}; + +export const WithOnlyProjects = () => { + const projectFiles = [ + fakeFileMetadataAndStorageProviderNameForCloudProject, + fakeFileMetadataAndStorageProviderNameForLocalProject, + ]; + + const preferences: Preferences = { + ...initialPreferences, + values: { + ...initialPreferences.values, + recentProjectFiles: projectFiles, + }, + getRecentProjectFiles: () => projectFiles, + }; + + const [currentPage, setCurrentPage] = React.useState(1); + const [searchText, setSearchText] = React.useState(''); + + return ( + + + + + + ); +}; + +export const WithGamesAndProjects = () => { + const projectFiles = [ + fakeFileMetadataAndStorageProviderNameForCloudProject, + fakeFileMetadataAndStorageProviderNameForLocalProject, + { + ...fakeFileMetadataAndStorageProviderNameForLocalProject, + fileMetadata: { + ...fakeFileMetadataAndStorageProviderNameForLocalProject.fileMetadata, + gameId: game1.id, + }, + }, + ]; + + const preferences: Preferences = { + ...initialPreferences, + values: { + ...initialPreferences.values, + recentProjectFiles: projectFiles, + }, + getRecentProjectFiles: () => projectFiles, + }; + + const [currentPage, setCurrentPage] = React.useState(1); + const [searchText, setSearchText] = React.useState(''); + return ( - - - + + + + + ); }; diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarnings.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarningsWidget.stories.js similarity index 91% rename from newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarnings.stories.js rename to newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarningsWidget.stories.js index 260279bcf3af..14858fe78eaa 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarnings.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarningsWidget.stories.js @@ -3,7 +3,7 @@ import * as React from 'react'; import paperDecorator from '../../../PaperDecorator'; -import UserEarnings from '../../../../GameDashboard/Monetization/UserEarnings'; +import UserEarningsWidget from '../../../../GameDashboard/Monetization/UserEarningsWidget'; import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext'; import { fakeSilverAuthenticatedUser } from '../../../../fixtures/GDevelopServicesTestData'; @@ -12,8 +12,8 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; export default { - title: 'GameDashboard/Monetization/UserEarnings', - component: UserEarnings, + title: 'GameDashboard/Monetization/UserEarningsWidget', + component: UserEarningsWidget, decorators: [paperDecorator], }; @@ -30,7 +30,7 @@ export const Errored = () => { return ( - + ); }; @@ -58,7 +58,7 @@ export const NoEarnings = () => { return ( - + ); }; @@ -86,7 +86,7 @@ export const LittleEarnings = () => { return ( - + ); }; @@ -114,7 +114,7 @@ export const SomeEarnings = () => { return ( - + ); }; @@ -142,7 +142,7 @@ export const ALotOfEarnings = () => { return ( - + ); }; diff --git a/newIDE/app/src/stories/componentStories/HomePage/HomePage.stories.js b/newIDE/app/src/stories/componentStories/HomePage/HomePage.stories.js index f7cfc47893cb..a193c919969e 100644 --- a/newIDE/app/src/stories/componentStories/HomePage/HomePage.stories.js +++ b/newIDE/app/src/stories/componentStories/HomePage/HomePage.stories.js @@ -128,6 +128,7 @@ const WrappedHomePage = ({ fetchGames: async () => {}, gamesFetchingError: null, onGameUpdated: () => {}, + markGameAsSavedIfRelevant: async () => {}, }} /> diff --git a/newIDE/app/src/stories/componentStories/ProjectManager/ProjectManager.stories.js b/newIDE/app/src/stories/componentStories/ProjectManager/ProjectManager.stories.js index 1ce53c448a91..2cdfab2dd89f 100644 --- a/newIDE/app/src/stories/componentStories/ProjectManager/ProjectManager.stories.js +++ b/newIDE/app/src/stories/componentStories/ProjectManager/ProjectManager.stories.js @@ -58,6 +58,7 @@ export const Default = () => ( fetchGames: async () => {}, gamesFetchingError: null, onGameUpdated: () => {}, + markGameAsSavedIfRelevant: async () => {}, }} onOpenHomePage={action('openHomepage')} toggleProjectManager={action('toggleProjectManager')} @@ -110,6 +111,7 @@ export const ErrorsInFunctions = () => ( fetchGames: async () => {}, gamesFetchingError: null, onGameUpdated: () => {}, + markGameAsSavedIfRelevant: async () => {}, }} onOpenHomePage={action('openHomepage')} toggleProjectManager={action('toggleProjectManager')} diff --git a/newIDE/app/src/stories/componentStories/QuickCustomization/QuickPublish.stories.js b/newIDE/app/src/stories/componentStories/QuickCustomization/QuickPublish.stories.js index 96cb9c7ff868..9acaa60a65db 100644 --- a/newIDE/app/src/stories/componentStories/QuickCustomization/QuickPublish.stories.js +++ b/newIDE/app/src/stories/componentStories/QuickCustomization/QuickPublish.stories.js @@ -50,7 +50,7 @@ const erroringOnlineWebExporter: Exporter = { exportPipeline: fakeErroringBrowserOnlineWebExportPipeline, }; -const Template = ({ children }: { children: React.Node }) => { +const Template = ({ children }: {| children: React.Node |}) => { const fakeGame = fakeGameAndBuildsManager.game; if (!fakeGame) throw new Error(