From bff1ce8d5dd65c835d8793b23f5d4cef591a4967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:05:44 +0100 Subject: [PATCH 01/26] Wip dashboard refreshes --- .../app/src/AssetStore/ExampleStore/index.js | 52 +- newIDE/app/src/GameDashboard/GameCard.js | 138 +++-- newIDE/app/src/GameDashboard/GamesList.js | 518 ++++++++++++++---- .../Monetization/UserEarnings.js | 263 --------- .../Monetization/UserEarningsWidget.js | 235 ++++++++ newIDE/app/src/GameDashboard/ProjectCard.js | 347 ++++++++++++ .../src/GameDashboard/Wallet/WalletWidget.js | 64 +++ .../Widgets/AllFeedbacksWidget.js | 49 ++ .../GameDashboard/Widgets/AnalyticsWidget.js | 4 +- .../src/GameDashboard/Widgets/BuildsWidget.js | 2 +- .../GameDashboard/Widgets/DashboardWidget.js | 22 +- .../GameDashboard/Widgets/FeedbackWidget.js | 4 +- .../GameDashboard/Widgets/TotalPlaysWidget.js | 123 +++++ .../src/Leaderboard/UseLeaderboardReplacer.js | 8 +- .../HomePage/BuildSection/index.js | 77 +-- .../AvatarWithStatusAndTooltip.js | 44 ++ .../CreateSection/LastModificationInfo.js | 120 ++++ .../HomePage/CreateSection/ProjectFileList.js | 26 - .../CreateSection/ProjectFileListItem.js | 145 +---- .../HomePage/CreateSection/index.js | 480 ++++++++++++++++ .../HomePage/GetStartedSection/EarnBadges.js | 168 ++++-- .../GetStartedSection/RecommendationList.js | 1 + .../EditorContainers/HomePage/HomePageMenu.js | 27 +- .../HomePage/HomePageMenuBar.js | 3 +- .../HomePage/ManageSection/index.js | 425 -------------- .../EditorContainers/HomePage/index.js | 71 +-- .../Preferences/PreferencesContext.js | 8 - .../Preferences/PreferencesProvider.js | 15 - newIDE/app/src/MainFrame/RouterContext.js | 3 +- .../UseMultiplayerLobbyConfigurator.js | 8 +- .../QuickCustomizationGameTiles.js | 46 +- newIDE/app/src/UI/BackgroundText.js | 3 +- newIDE/app/src/UI/ErrorBoundary.js | 3 +- newIDE/app/src/Utils/GDevelopServices/Game.js | 3 +- newIDE/app/src/Utils/UseCreateProject.js | 8 +- .../app/src/Utils/UseGameAndBuildsManager.js | 25 +- .../GameDashboard/GamesList.stories.js | 1 + 37 files changed, 2219 insertions(+), 1320 deletions(-) delete mode 100644 newIDE/app/src/GameDashboard/Monetization/UserEarnings.js create mode 100644 newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js create mode 100644 newIDE/app/src/GameDashboard/ProjectCard.js create mode 100644 newIDE/app/src/GameDashboard/Wallet/WalletWidget.js create mode 100644 newIDE/app/src/GameDashboard/Widgets/AllFeedbacksWidget.js create mode 100644 newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js create mode 100644 newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/AvatarWithStatusAndTooltip.js create mode 100644 newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo.js create mode 100644 newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js delete mode 100644 newIDE/app/src/MainFrame/EditorContainers/HomePage/ManageSection/index.js diff --git a/newIDE/app/src/AssetStore/ExampleStore/index.js b/newIDE/app/src/AssetStore/ExampleStore/index.js index eacea85ddfda..7e3ae7ddc40e 100644 --- a/newIDE/app/src/AssetStore/ExampleStore/index.js +++ b/newIDE/app/src/AssetStore/ExampleStore/index.js @@ -49,6 +49,7 @@ type Props = {| row: number, element: React.Node, |}, + hideSearch?: boolean, |}; const ExampleStore = ({ @@ -58,8 +59,9 @@ const ExampleStore = ({ onlyShowGames, columnsCount, rowToInsert, + hideSearch, }: Props) => { - const { receivedGameTemplates } = React.useContext(AuthenticatedUserContext); + const authenticatedUser = React.useContext(AuthenticatedUserContext); const { exampleShortHeadersSearchResults, fetchExamplesAndFilters, @@ -123,7 +125,7 @@ const ExampleStore = ({ const resultTiles: React.Node[] = React.useMemo( () => { return getExampleAndTemplateTiles({ - receivedGameTemplates, + receivedGameTemplates: authenticatedUser.receivedGameTemplates, privateGameTemplateListingDatas: privateGameTemplateListingDatasSearchResults ? privateGameTemplateListingDatasSearchResults .map(({ item }) => item) @@ -160,7 +162,7 @@ const ExampleStore = ({ }).allGridItems; }, [ - receivedGameTemplates, + authenticatedUser, privateGameTemplateListingDatasSearchResults, exampleShortHeadersSearchResults, onSelectPrivateGameTemplateListingData, @@ -183,15 +185,17 @@ const ExampleStore = ({ numberOfTilesToDisplayUntilRowToInsert ); return [ - - {firstTiles} - , + firstTiles.length > 0 ? ( + + {firstTiles} + + ) : null, rowToInsert ? ( {rowToInsert.element} ) : null, @@ -214,17 +218,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/GameDashboard/GameCard.js b/newIDE/app/src/GameDashboard/GameCard.js index 9002c5d159e3..cebce184655c 100644 --- a/newIDE/app/src/GameDashboard/GameCard.js +++ b/newIDE/app/src/GameDashboard/GameCard.js @@ -15,6 +15,7 @@ import { 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, @@ -44,18 +45,20 @@ const styles = { type Props = {| game: Game, - isCurrentGame: boolean, + isCurrentProjectOpened: boolean, onOpenGameManager: () => void, storageProviders: Array, onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, + askToCloseProject: () => Promise, |}; export const GameCard = ({ storageProviders, game, - isCurrentGame, + isCurrentProjectOpened, onOpenGameManager, onOpenProject, + askToCloseProject, }: Props) => { useOnResize(useForceUpdate()); const projectsList = useProjectsListFor(game); @@ -124,9 +127,14 @@ export const GameCard = ({ const renderTitle = (i18n: I18nType) => ( - - Published on {i18n.date(game.createdAt * 1000)} - + + + Last edited: + + + {i18n.date(game.updatedAt * 1000)} + + {game.gameName} @@ -148,6 +156,52 @@ export const GameCard = ({ /> ); + const buildContextMenu = ( + i18n: I18nType, + projectsList: FileMetadataAndStorageProviderName[] + ): Array => { + const actions = + projectsList.length > 1 + ? [ + ...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, + }, + ] + : []; + + if (isCurrentProjectOpened) { + actions.push( + { type: 'separator' }, + { + label: i18n._(t`Close project`), + click: async () => { + await askToCloseProject(); + }, + } + ); + } + + return actions; + }; + const renderButtons = ({ fullWidth }: { fullWidth: boolean }) => { return (
@@ -164,55 +218,41 @@ export const GameCard = ({ } onClick={onOpenGameManager} /> - {projectsList.length === 0 ? null : projectsList.length === 1 ? ( + {projectsList.length === 0 ? null : projectsList.length === 1 && + !isCurrentProjectOpened ? ( Opened - ) : isWidthConstrained ? ( + isWidthConstrained ? ( Open ) : ( Open project ) } - onClick={() => onOpenProject(projectsList[0])} + onClick={ + isCurrentProjectOpened + ? undefined + : () => onOpenProject(projectsList[0]) + } /> ) : ( Opened : Open + isCurrentProjectOpened ? ( + Opened + ) : ( + Open + ) + } + onClick={ + isCurrentProjectOpened + ? undefined + : () => onOpenProject(projectsList[0]) } - 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, - }, - ]} + buildMenuTemplate={i18n => buildContextMenu(i18n, projectsList)} /> )} @@ -229,7 +269,7 @@ export const GameCard = ({ {isMobile ? ( @@ -250,15 +290,17 @@ export const GameCard = ({ justifyContent="space-between" noOverflowParent > - - {renderTitle(i18n)} - {renderButtons({ fullWidth: false })} - - {renderPublicInfo()} + + + {renderTitle(i18n)} + {renderButtons({ fullWidth: false })} + + {renderPublicInfo()} + {renderShareUrl(i18n)} diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 5269f1f15f4e..cf56d7f99813 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -4,7 +4,11 @@ import Fuse from 'fuse.js'; import { Trans, t } from '@lingui/macro'; import { type Game } from '../Utils/GDevelopServices/Game'; import { GameCard } from './GameCard'; -import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout'; +import { + ColumnStackLayout, + LineStackLayout, + ResponsiveLineStackLayout, +} from '../UI/Layout'; import SearchBar from '../UI/SearchBar'; import { useDebounce } from '../Utils/UseDebounce'; import { @@ -20,59 +24,173 @@ 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 FlatButton from '../UI/FlatButton'; +import { + getLastModifiedInfoByProjectId, + useProjectsListFor, +} from '../MainFrame/EditorContainers/HomePage/CreateSection/utils'; +import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; +import Refresh from '../UI/CustomSvgIcons/Refresh'; +import ProjectCard from './ProjectCard'; const pageSize = 10; -const styles = { noGameMessageContainer: { padding: 10 } }; +const styles = { + noGameMessageContainer: { padding: 10 }, + refreshIconContainer: { fontSize: 20, display: 'flex', alignItems: 'center' }, +}; -const getGamesToDisplay = ({ - project, - games, +type OrderBy = 'totalSessions' | 'weeklySessions' | 'lastModifiedAt'; + +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. +|}; + +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; +}; + +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 getDashboardItemsToDisplay = ({ + 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: OrderBy, +|}): Array => { + let itemsToDisplay: DashboardItem[] = allDashboardItems; + + // Always order items, with or without search. + itemsToDisplay = + orderBy === 'totalSessions' + ? itemsToDisplay.sort(totalSessionsSort) + : orderBy === 'weeklySessions' + ? itemsToDisplay.sort(lastWeekSessionsSort) + : orderBy === 'lastModifiedAt' + ? itemsToDisplay.sort(lastModifiedAtSort) + : itemsToDisplay; + if (searchText) { const searchResults = searchClient.search( getFuseSearchQueryForSimpleArray(searchText) ); - return searchResults.map(result => result.item); + itemsToDisplay = searchResults.map(result => result.item); + } else { + // If a project is opened and no search is performed, display it first. + if (currentFileMetadata) { + const dashboardItemLinkedToOpenedFileMetadata = allDashboardItems.find( + dashboardItem => + dashboardItem.projectFiles && + dashboardItem.projectFiles.some( + projectFile => + projectFile.fileMetadata.gameId === currentFileMetadata.gameId && + projectFile.fileMetadata.fileIdentifier === + currentFileMetadata.fileIdentifier + ) + ); + if (dashboardItemLinkedToOpenedFileMetadata) { + itemsToDisplay = [ + dashboardItemLinkedToOpenedFileMetadata, + ...itemsToDisplay.filter( + item => + !areDashboardItemsEqual( + item, + dashboardItemLinkedToOpenedFileMetadata + ) + ), + ]; + } else { + // Could not find the dashboard item linked to the opened project. This can happen if the project + // has been removed from the list of recent projects, for example. + // In this case, add the opened project first in the list. + const openedProjectDashboardItem: DashboardItem = { + projectFiles: [ + { + fileMetadata: currentFileMetadata, + // We're not sure about the storage provider, so we leave it empty. + storageProviderName: '', + }, + ], + }; + itemsToDisplay = [openedProjectDashboardItem, ...itemsToDisplay]; + } + } } - 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( + return itemsToDisplay.slice( currentPage * pageSize, (currentPage + 1) * pageSize ); @@ -81,38 +199,79 @@ const getGamesToDisplay = ({ type Props = {| storageProviders: Array, project: ?gdProject, + currentFileMetadata: ?FileMetadata, games: Array, onRefreshGames: () => Promise, onOpenGameId: (gameId: ?string) => void, onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, + canOpen: boolean, + onOpenNewProjectSetupDialog: () => void, + onChooseProject: () => void, + closeProject: () => Promise, + askToCloseProject: () => Promise, |}; const GamesList = ({ project, + currentFileMetadata, games, onRefreshGames, onOpenGameId, onOpenProject, storageProviders, + canOpen, + onOpenNewProjectSetupDialog, + onChooseProject, + closeProject, + askToCloseProject, }: Props) => { - const { values, setGamesListOrderBy } = React.useContext(PreferencesContext); + const { cloudProjects, profile, onCloudProjectsChanged } = React.useContext( + AuthenticatedUserContext + ); + const [orderBy, setGamesListOrderBy] = React.useState( + 'lastModifiedAt' + ); const [searchText, setSearchText] = React.useState(''); const [currentPage, setCurrentPage] = React.useState(0); - const { gamesListOrderBy: orderBy } = values; + const { isMobile } = useResponsiveWindowSize(); + + const allRecentProjectFiles = useProjectsListFor(null); + const allDashboardItems: DashboardItem[] = React.useMemo( + () => { + 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 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({ - project, - games, + const [displayedDashboardItems, setDisplayedDashboardItems] = React.useState< + Array + >( + getDashboardItemsToDisplay({ + currentFileMetadata, + allDashboardItems, searchText, searchClient, currentPage, @@ -120,12 +279,12 @@ const GamesList = ({ }) ); - const getGamesToDisplayDebounced = useDebounce( + const getDashboardItemsToDisplayDebounced = useDebounce( () => { - setDisplayedGames( - getGamesToDisplay({ - project, - games, + setDisplayedDashboardItems( + getDashboardItemsToDisplay({ + currentFileMetadata, + allDashboardItems, searchText, searchClient, currentPage, @@ -143,97 +302,214 @@ const GamesList = ({ // - search text changes (user input) // - games change (refresh following an update for instance) // - user changes page - React.useEffect(getGamesToDisplayDebounced, [ - getGamesToDisplayDebounced, + // - opened project changes + React.useEffect(getDashboardItemsToDisplayDebounced, [ + getDashboardItemsToDisplayDebounced, searchText, games, currentPage, orderBy, + currentFileMetadata, ]); const projectUuid = project ? project.getProjectUuid() : null; + 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, + } + ); + setLastModifiedInfoByProjectId(_lastModifiedInfoByProjectId); + }; + + updateModificationInfoByProjectId(); + }, + [cloudProjects, profile] + ); + return ( - - - Published games - - - - - // $FlowFixMe - setGamesListOrderBy(value) - } - > - - - - - - - {}} - placeholder={t`Search by name`} - autoFocus="desktop" - /> - - setCurrentPage(currentPage => currentPage - 1)} - disabled={!!searchText || currentPage === 0} - size="small" - > - - - - {searchText ? 1 : currentPage + 1} + + + + Games setCurrentPage(currentPage => currentPage + 1)} - disabled={ - !!searchText || (currentPage + 1) * pageSize >= games.length - } size="small" + onClick={refreshGamesList} + disabled={isRefreshing} + tooltip={t`Refresh games`} > - +
+ +
-
-
- {displayedGames.length > 0 ? ( - displayedGames.map(game => ( - { - onOpenGameId(game.id); - }} - onOpenProject={onOpenProject} + + + Create : Create new game + } + onClick={onOpenNewProjectSetupDialog} + icon={} + id="home-create-project-button" /> - )) + {canOpen && ( + Open : Open a project + } + onClick={onChooseProject} + /> + )} + + + {allDashboardItems.length > 0 && ( + + + // $FlowFixMe + setGamesListOrderBy(value) + } + > + + + + + + + {}} + 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" + > + + + + + )} + {displayedDashboardItems.length > 0 ? ( + displayedDashboardItems + .map((dashboardItem, index) => { + const game = dashboardItem.game; + if (game) { + return ( + { + onOpenGameId(game.id); + }} + onOpenProject={onOpenProject} + askToCloseProject={askToCloseProject} + /> + ); + } + const projectFiles = dashboardItem.projectFiles; + if (projectFiles) { + const projectFileMetadataAndStorageProviderName = projectFiles[0]; + return ( + + onOpenProject(projectFileMetadataAndStorageProviderName) + } + lastModifiedInfo={ + lastModifiedInfoByProjectId[ + projectFileMetadataAndStorageProviderName.fileMetadata + .fileIdentifier + ] + } + isCurrentProjectOpened={ + !!projectUuid && + projectFileMetadataAndStorageProviderName.fileMetadata + .gameId === projectUuid + } + currentFileMetadata={currentFileMetadata} + closeProject={closeProject} + askToCloseProject={askToCloseProject} + onRefreshGames={refreshGamesList} + /> + ); + } + + return null; + }) + .filter(Boolean) ) : !!searchText ? ( { - 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..31179cfaebc5 --- /dev/null +++ b/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js @@ -0,0 +1,235 @@ +// @flow +import { Trans } from '@lingui/macro'; +import * as React from 'react'; + +import { LineStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout'; +import Text from '../../UI/Text'; +import { Column, 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 Bank from '../../UI/CustomSvgIcons/Bank'; +import { Tooltip } from '@material-ui/core'; +import CreditOutDialog from './CashOutDialog'; +import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; +import DashboardWidget from '../Widgets/DashboardWidget'; + +const styles = { + separator: { + height: 50, + }, + buttonContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + }, +}; + +const UserEarningsWidget = () => { + 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 = error ? ( + + + + Can't load the total earnings. Verify your internet connection or try + again later. + + + + ) : ( + + + + Window.openExternalURL( + 'https://wiki.gdevelop.io/gdevelop5/monetization/' + ) + } + > + Learn about revenue on gd.games + + + + + + + {(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 ( + <> + Total earnings}> + {content} + + {selectedCashOutType && userEarningsBalance && ( + setSelectedCashOutType(null)} + onSuccess={onRefreshEarningsBalance} + type={selectedCashOutType} + /> + )} + + ); +}; + +export default UserEarningsWidget; diff --git a/newIDE/app/src/GameDashboard/ProjectCard.js b/newIDE/app/src/GameDashboard/ProjectCard.js new file mode 100644 index 000000000000..c451f5444b2e --- /dev/null +++ b/newIDE/app/src/GameDashboard/ProjectCard.js @@ -0,0 +1,347 @@ +// @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 } from '../UI/Layout'; +import { + type FileMetadataAndStorageProviderName, + type StorageProvider, +} from '../ProjectsStorage'; +import Text from '../UI/Text'; +import Card from '../UI/Card'; +import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; +import { + getStorageProviderByInternalName, + type LastModifiedInfo, +} from '../MainFrame/EditorContainers/HomePage/CreateSection/utils'; +import FlatButtonWithSplitMenu from '../UI/FlatButtonWithSplitMenu'; +import useOnResize from '../Utils/UseOnResize'; +import useForceUpdate from '../Utils/UseForceUpdate'; +import LastModificationInfo from '../MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo'; +import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; +import { type FileMetadata } from '../ProjectsStorage'; +import { Line, Spacer } from '../UI/Grid'; +import { type MenuItemTemplate } from '../UI/Menu/Menu.flow'; +import optionalRequire from '../Utils/OptionalRequire'; +import useAlertDialog from '../UI/Alert/useAlertDialog'; +import { deleteCloudProject } from '../Utils/GDevelopServices/Project'; +import { extractGDevelopApiErrorStatusAndCode } from '../Utils/GDevelopServices/Errors'; +import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; +import { registerGame } from '../Utils/GDevelopServices/Game'; +import { getDefaultRegisterGameProperties } from '../Utils/UseGameAndBuildsManager'; +const electron = optionalRequire('electron'); +const path = optionalRequire('path'); + +const styles = { + buttonsContainer: { display: 'flex', flexShrink: 0 }, + iconAndText: { display: 'flex', gap: 2, alignItems: 'flex-start' }, +}; + +const locateProjectFile = (file: FileMetadataAndStorageProviderName) => { + if (!electron) return; + electron.shell.showItemInFolder( + path.resolve(file.fileMetadata.fileIdentifier) + ); +}; + +type Props = {| + projectFileMetadataAndStorageProviderName: FileMetadataAndStorageProviderName, + isCurrentProjectOpened: boolean, + onOpenProject: () => Promise, + lastModifiedInfo: LastModifiedInfo | null, + storageProviders: Array, + currentFileMetadata: ?FileMetadata, + closeProject: () => Promise, + askToCloseProject: () => Promise, + onRefreshGames: () => Promise, +|}; + +const ProjectCard = ({ + projectFileMetadataAndStorageProviderName, + isCurrentProjectOpened, + onOpenProject, + lastModifiedInfo, + storageProviders, + currentFileMetadata, + closeProject, + askToCloseProject, + onRefreshGames, +}: Props) => { + useOnResize(useForceUpdate()); + const authenticatedUser = React.useContext(AuthenticatedUserContext); + const { removeRecentProjectFile } = React.useContext(PreferencesContext); + const { isMobile } = useResponsiveWindowSize(); + const { + showDeleteConfirmation, + showConfirmation, + showAlert, + } = useAlertDialog(); + const fileMetadata = projectFileMetadataAndStorageProviderName.fileMetadata; + const projectName = fileMetadata.name || 'Unknown project'; + const storageProvider = getStorageProviderByInternalName( + storageProviders, + projectFileMetadataAndStorageProviderName.storageProviderName + ); + const [isLoading, setIsLoading] = React.useState(false); + + const renderTitle = (i18n: I18nType) => ( + + {fileMetadata.lastModifiedDate && ( + + {storageProvider && ( + + {storageProvider.renderIcon && ( + <> + {storageProvider.renderIcon({ + size: 'small', + })} + + + )} + + {i18n._(storageProvider.name)} + + + )} + Last edited: + } + /> + + )} + + {projectName} + + + ); + + 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. + + 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(); + } + + // 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 { + setIsLoading(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 saving the project. Please try again later.`; + showAlert({ + title: t`Unable to delete the project`, + message, + }); + } finally { + setIsLoading(false); + } + }; + + const onRegisterProject = React.useCallback( + async () => { + const projectId = fileMetadata.gameId; + if (!authenticatedUser.profile || !projectId) return; + + const { id, username } = authenticatedUser.profile; + try { + setIsLoading(true); + await registerGame( + authenticatedUser.getAuthorizationHeader, + id, + getDefaultRegisterGameProperties({ + projectId, + projectName: fileMetadata.name, + projectAuthor: username, + }) + ); + 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 { + setIsLoading(false); + } + }, + [ + authenticatedUser.getAuthorizationHeader, + authenticatedUser.profile, + fileMetadata.gameId, + fileMetadata.name, + onRefreshGames, + showAlert, + ] + ); + + const buildContextMenu = ( + i18n: I18nType, + file: ?FileMetadataAndStorageProviderName + ): Array => { + if (!file) return []; + + const actions = [ + { + label: i18n._(t`Register the game online`), + click: () => onRegisterProject(), + }, + ]; + + if (file.storageProviderName === 'Cloud') { + actions.push({ + label: i18n._(t`Delete`), + click: () => onDeleteCloudProject(i18n, file), + }); + } else { + if (file.storageProviderName === 'LocalFile') { + actions.push({ + label: i18n._(t`Show in local folder`), + click: () => locateProjectFile(file), + }); + } + + // Don't allow removing project if opened, as it would not result in any change in the list. + if (!isCurrentProjectOpened) { + actions.push( + { + type: 'separator', + }, + { + label: i18n._(t`Remove from list`), + click: () => removeRecentProjectFile(file), + } + ); + } + } + + if (isCurrentProjectOpened) { + actions.push( + { + type: 'separator', + }, + { + label: i18n._(t`Close project`), + click: async () => { + await askToCloseProject(); + }, + } + ); + } + + return actions; + }; + + const renderButtons = ({ fullWidth }: { fullWidth: boolean }) => { + return ( +
+ + Opened + ) : ( + Open + ) + } + onClick={isCurrentProjectOpened ? undefined : onOpenProject} + buildMenuTemplate={i18n => + buildContextMenu(i18n, projectFileMetadataAndStorageProviderName) + } + /> + +
+ ); + }; + + return ( + + {({ i18n }) => ( + + {isMobile ? ( + + {renderTitle(i18n)} + {renderButtons({ fullWidth: true })} + + ) : ( + + + + {renderTitle(i18n)} + {renderButtons({ fullWidth: false })} + + + + )} + + )} + + ); +}; + +export default ProjectCard; diff --git a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js new file mode 100644 index 000000000000..711bc58d9fbc --- /dev/null +++ b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js @@ -0,0 +1,64 @@ +// @flow + +import * as React from 'react'; +import { Trans } from '@lingui/macro'; +import DashboardWidget from '../Widgets/DashboardWidget'; +import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; +import Coin from '../../Credits/Icons/Coin'; +import Text from '../../UI/Text'; +import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; +import { + EarnBadges, + hasMissingBadges, +} from '../../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges'; +import FlatButton from '../../UI/FlatButton'; + +type Props = {| + onOpenProfile: () => void, + fullWidth?: boolean, + showRandomBadge?: boolean, +|}; + +const WalletWidget = ({ onOpenProfile, fullWidth, showRandomBadge }: Props) => { + const { + profile, + limits, + achievements, + badges, + onOpenCreateAccountDialog, + } = React.useContext(AuthenticatedUserContext); + const creditsAvailable = limits ? limits.credits.userBalance.amount : 0; + return ( + Wallet} + topRightAction={ + + {hasMissingBadges(badges, achievements) && ( + Claim in profile} + onClick={profile ? onOpenProfile : onOpenCreateAccountDialog} + /> + )} + + + {creditsAvailable} Credits + + + } + minHeight="small" + > + + + + + ); +}; + +export default WalletWidget; diff --git a/newIDE/app/src/GameDashboard/Widgets/AllFeedbacksWidget.js b/newIDE/app/src/GameDashboard/Widgets/AllFeedbacksWidget.js new file mode 100644 index 000000000000..99a2243ad353 --- /dev/null +++ b/newIDE/app/src/GameDashboard/Widgets/AllFeedbacksWidget.js @@ -0,0 +1,49 @@ +// @flow + +import * as React from 'react'; +import { I18n } from '@lingui/react'; +import { Trans } from '@lingui/macro'; +import DashboardWidget from './DashboardWidget'; +import { type Comment } from '../../Utils/GDevelopServices/Play'; +import { Line } from '../../UI/Grid'; +import Text from '../../UI/Text'; +import NotificationDot from '../NotificationDot'; + +type Props = {| + feedbacks: Array, +|}; + +const AllFeedbacksWidget = ({ feedbacks }: Props) => { + const unprocessedFeedbacks = feedbacks.filter( + comment => !comment.processedAt + ); + + return ( + + {({ i18n }) => ( + Feedbacks} + topRightAction={ + + {!!unprocessedFeedbacks.length && ( + + )} + + {unprocessedFeedbacks.length === 0 ? ( + No new feedback + ) : unprocessedFeedbacks.length === 1 ? ( + 1 new feedback + ) : ( + {unprocessedFeedbacks.length} new feedbacks + )} + + + } + /> + )} + + ); +}; + +export default AllFeedbacksWidget; diff --git a/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js b/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js index 6c6bddd05bca..17ef5aca1444 100644 --- a/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js @@ -53,9 +53,9 @@ const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => { {({ i18n }) => ( Analytics} - seeMoreButton={ + topRightAction={ See all} rightIcon={} diff --git a/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js b/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js index 43009c469fe8..bcbdd45ae84b 100644 --- a/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js @@ -34,7 +34,7 @@ const BuildsWidget = ({ builds, onSeeAllBuilds }: Props) => { Exports} - seeMoreButton={ + topRightAction={ See all} rightIcon={} diff --git a/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js b/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js index 24ee80a8f8c1..d1eb4b52ffe3 100644 --- a/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js @@ -8,7 +8,8 @@ import { Column, Line } from '../../UI/Grid'; import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; const verticalPadding = 8; -const fixedHeight = 300; +const largeFixedHeight = 300; +const smallFixedHeight = 150; const styles = { paper: { @@ -25,27 +26,26 @@ const styles = { flex: 1, }, maxHeightNotWrapped: { - minHeight: fixedHeight, height: `calc(100% - ${2 * verticalPadding}px)`, }, }; type Props = {| title: React.Node, - seeMoreButton?: React.Node, + topRightAction?: React.Node, renderSubtitle?: ?() => React.Node, gridSize: number, children?: React.Node, - withMinHeight?: boolean, + minHeight?: 'small' | 'large', |}; const DashboardWidget = ({ title, - seeMoreButton, + topRightAction, gridSize, renderSubtitle, children, - withMinHeight, + minHeight, }: Props) => { const { isMobile } = useResponsiveWindowSize(); return ( @@ -54,8 +54,12 @@ const DashboardWidget = ({ background="medium" style={{ ...styles.paper, - ...(withMinHeight && !isMobile - ? styles.maxHeightNotWrapped + ...(minHeight && !isMobile + ? { + ...styles.maxHeightNotWrapped, + minHeight: + minHeight === 'large' ? largeFixedHeight : smallFixedHeight, + } : undefined), }} > @@ -67,7 +71,7 @@ const DashboardWidget = ({ {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..a9c9d2f63188 100644 --- a/newIDE/app/src/GameDashboard/Widgets/FeedbackWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/FeedbackWidget.js @@ -50,7 +50,7 @@ const FeedbackWidget = ({ Feedbacks} - seeMoreButton={ + topRightAction={ !feedbacks || feedbacks.length === 0 ? null : ( See more} @@ -60,7 +60,7 @@ const FeedbackWidget = ({ /> ) } - withMinHeight + minHeight="large" renderSubtitle={() => shouldDisplayControlToCollectFeedback ? null : unprocessedFeedbacks && feedbacks ? ( diff --git a/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js b/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js new file mode 100644 index 000000000000..53c8d9afb90b --- /dev/null +++ b/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js @@ -0,0 +1,123 @@ +// @flow +import { Trans } from '@lingui/macro'; +import * as React from 'react'; + +import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; +import Text from '../../UI/Text'; +import { Column, Line, Spacer } from '../../UI/Grid'; +import BackgroundText from '../../UI/BackgroundText'; +import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; +import DashboardWidget from './DashboardWidget'; +import { type Game } from '../../Utils/GDevelopServices/Game'; + +const styles = { + separator: { + height: 50, + }, +}; + +// Helper to display 5.5k instead of 5502, or 1.2m instead of 1234567 +export const formatPlays = (plays: number) => { + if (plays < 1000) return plays.toString(); + if (plays < 1000000) return `${(plays / 1000).toFixed(1)}k`; + return `${(plays / 1000000).toFixed(1)}m`; +}; + +type Props = {| + games: Array, +|}; + +const TotalPlaysWidget = ({ games }: Props) => { + const theme = React.useContext(GDevelopThemeContext); + + const { + allGamesLastWeekPlays, + allGamesLastYearPlays, + allGamesTotalPlays, + } = React.useMemo( + () => { + const allGamesLastWeekPlays = games.reduce( + (acc, game) => acc + (game.cachedLastWeekSessionsCount || 0), + 0 + ); + const allGamesLastYearPlays = games.reduce( + (acc, game) => acc + (game.cachedLastYearSessionsCount || 0), + 0 + ); + const allGamesTotalPlays = games.reduce( + (acc, game) => acc + (game.cachedTotalSessionsCount || 0), + 0 + ); + + return { + allGamesLastWeekPlays, + allGamesLastYearPlays, + allGamesTotalPlays, + }; + }, + [games] + ); + + return ( + Total plays} + minHeight="small" + > + + + + + + {formatPlays(allGamesTotalPlays)} + + + Overall + + + + + + + + {formatPlays(allGamesLastWeekPlays)} + + + Last week + + + +
+ + + + {formatPlays(allGamesLastYearPlays)} + + + Last year + + + + + + + + ); +}; + +export default TotalPlaysWidget; diff --git a/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js b/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js index 54f95fb3f394..238174832a26 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,11 @@ export const replaceLeaderboardsInProject = async ({ await registerGame( getAuthorizationHeader, profile.id, - getDefaultRegisterGamePropertiesFromProject({ project }) + getDefaultRegisterGameProperties({ + projectId: project.getProjectUuid(), + projectName: project.getName(), + projectAuthor: project.getAuthor(), + }) ); } 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 index bec74bcfff78..ecea96b59b5e 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js @@ -40,7 +40,6 @@ 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'; @@ -124,17 +123,10 @@ const BuildSection = ({ 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(); @@ -144,30 +136,6 @@ const BuildSection = ({ 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({ @@ -179,9 +147,7 @@ const BuildSection = ({ i18n, numberOfItemsExclusivelyInCarousel: isMobile ? 3 : 5, numberOfItemsInCarousel: isMobile ? 8 : 12, - privateGameTemplatesPeriodicity: shouldDisplayPremiumGameTemplates - ? 2 - : 0, + privateGameTemplatesPeriodicity: 2, }), [ authenticatedUser.receivedGameTemplates, @@ -191,15 +157,9 @@ const BuildSection = ({ privateGameTemplateListingDatas, i18n, isMobile, - shouldDisplayPremiumGameTemplates, ] ); - const shouldDisplayAnnouncements = - !authenticatedUser.limits || - !authenticatedUser.limits.capabilities.classrooms || - !authenticatedUser.limits.capabilities.classrooms.hidePlayTab; - const pageContent = showAllGameTemplates ? ( setShowAllGameTemplates(false)} @@ -218,7 +178,6 @@ const BuildSection = ({ ) : ( ( @@ -251,14 +210,6 @@ const BuildSection = ({ roundedImages displayArrowsOnDesktop /> - {shouldDisplayAnnouncements && ( - <> - - - - - - )} My projects - -
- -
-
@@ -362,25 +303,13 @@ const BuildSection = ({
); - return ( - <> - {pageContent} - Log in to see your cloud projects.} - visible={showCloudProjectsInfoIfNotLoggedIn} - hide={() => setShowCloudProjectsInfoIfNotLoggedIn(false)} - duration={5000} - onActionClick={onOpenLoginDialog} - actionLabel={Log in} - /> - - ); + return pageContent; }; const BuildSectionWithErrorBoundary = (props: Props) => ( Build section} - scope="start-page-build" + scope="start-page-create" > 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..2f9197f2aef9 --- /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..2757c7d718be 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js @@ -16,11 +16,6 @@ import { 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'; @@ -101,13 +96,9 @@ const ProjectFileList = ({ const [pendingProject, setPendingProject] = React.useState(null); const contextMenu = React.useRef(null); const authenticatedUser = React.useContext(AuthenticatedUserContext); - const { openSubscriptionDialog } = React.useContext( - SubscriptionSuggestionContext - ); const { profile, cloudProjects, - limits, cloudProjectsFetchingErrorLabel, onCloudProjectsChanged, } = authenticatedUser; @@ -138,10 +129,6 @@ const ProjectFileList = ({ [cloudProjects, profile] ); - const hasTooManyCloudProjects = checkIfHasTooManyCloudProjects( - authenticatedUser - ); - const onDeleteCloudProject = async ( i18n: I18nType, { fileMetadata, storageProviderName }: FileMetadataAndStorageProviderName @@ -315,19 +302,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, +|}; + +const CreateSection = ({ + project, + currentFileMetadata, + onOpenProject, + storageProviders, + closeProject, + canOpen, + onOpenProfile, + askToCloseProject, + onCreateProjectFromExample, + onSelectPrivateGameTemplateListingData, + onSelectExampleShortHeader, + i18n, + games, + onRefreshGames, + onGameUpdated, + gamesFetchingError, + openedGame, + setOpenedGameId, + currentTab, + setCurrentTab, + onOpenNewProjectSetupDialog, + onChooseProject, +}: Props) => { + const authenticatedUser = React.useContext(AuthenticatedUserContext); + const { + profile, + getAuthorizationHeader, + loginState, + recommendations, + limits, + } = authenticatedUser; + const { showAlert, showConfirmation } = useAlertDialog(); + const [ + gameUnregisterErrorText, + setGameUnregisterErrorText, + ] = 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 hasTooManyCloudProjects = checkIfHasTooManyCloudProjects( + authenticatedUser + ); + + 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 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 { + 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] + ); + + if (openedGame) { + return ( + + + + ); + } + + if (showAllGameTemplates) { + return ( + setShowAllGameTemplates(false)} + flexBody + > + + + + + ); + } + + return ( + + {({ i18n }) => ( + ( + + + + openSubscriptionDialog({ + analyticsMetadata: { + reason: 'Cloud Project limit reached', + }, + }) + } + /> + + + ) + : undefined + } + > + + {!!profile || loginState === 'done' ? ( + + {games && games.length !== 0 ? ( + + + + Performance Dashboard + + + + + + + + + ) : ( + + + + )} + + {/* Check if looks ok */} + {isMobile && limits && hasTooManyCloudProjects && ( + + openSubscriptionDialog({ + analyticsMetadata: { + reason: 'Cloud Project limit reached', + }, + }) + } + /> + )} + {(!games || games.length === 0) && + !project && + quickCustomizationRecommendation && ( + + + + Publish your first game + + + { + const projectIsClosed = await askToCloseProject(); + if (!projectIsClosed) { + return; + } + + const newProjectSetup: NewProjectSetup = { + storageProvider: UrlStorageProvider, + saveAsLocation: null, + openQuickCustomizationDialog: true, + }; + onCreateProjectFromExample( + exampleShortHeader, + newProjectSetup, + i18n + ); + }} + quickCustomizationRecommendation={ + quickCustomizationRecommendation + } + /> + + + Remix an existing game + + 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/GetStartedSection/EarnBadges.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js index f467b583439f..12499ec94a6f 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js @@ -12,18 +12,13 @@ import { 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, -|}; +import FlatButton from '../../../../UI/FlatButton'; +import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer'; const getAchievement = (achievements: ?Array, id: string) => achievements && achievements.find(achievement => achievement.id === id); @@ -31,6 +26,16 @@ const getAchievement = (achievements: ?Array, id: string) => 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 styles = { badgeContainer: { position: 'relative', @@ -63,21 +68,16 @@ const styles = { }; const BadgeItem = ({ - achievements, - badges, - achievementId, + achievement, + hasThisBadge, buttonLabel, linkUrl, }: {| - achievements: ?Array, - badges: ?Array, - achievementId: string, + achievement: ?Achievement, + hasThisBadge: boolean, buttonLabel: React.Node, linkUrl: string, |}) => { - const achievement = getAchievement(achievements, achievementId); - const hasThisBadge = hasBadge(badges, achievementId); - return ( {({ i18n }) => ( @@ -121,14 +121,8 @@ const BadgeItem = ({ '-'} - - - Worth {achievement ? achievement.rewardValueInCredits : '-'}{' '} - credits. - - - { @@ -143,42 +137,104 @@ const BadgeItem = ({ ); }; -export const EarnBadges = ({ achievements, badges, onOpenProfile }: Props) => { +const allBadgesInfo = [ + { + id: 'github-star', + label: Star GDevelop, + linkUrl: 'https://github.com/4ian/GDevelop', + }, + { + id: 'twitter-follow', + label: Follow, + linkUrl: 'https://www.tiktok.com/@gdevelop', + }, + { + id: 'twitter-follow', + label: Follow, + linkUrl: 'https://x.com/GDevelopApp', + }, +]; + +type Props = {| + achievements: ?Array, + badges: ?Array, + onOpenProfile: () => void, + hideStatusBanner?: boolean, + showRandomBadge?: boolean, +|}; + +export const EarnBadges = ({ + achievements, + badges, + onOpenProfile, + hideStatusBanner, + showRandomBadge, +}: Props) => { + const { isMobile } = useResponsiveWindowSize(); + const badgesToShow = React.useMemo( + () => { + const allBadgesWithOwnedStatus = allBadgesInfo.map(badgeInfo => ({ + ...badgeInfo, + hasThisBadge: hasBadge(badges, badgeInfo.id), + })); + const notOwnedBadges = allBadgesWithOwnedStatus.filter( + badge => !badge.hasThisBadge + ); + + // Only show 1 badge on mobile to avoid taking too much space. + if (showRandomBadge || isMobile) { + if (notOwnedBadges.length === 0) { + const randomIndex = Math.floor( + Math.random() * allBadgesWithOwnedStatus.length + ); + return [allBadgesWithOwnedStatus[randomIndex]]; + } + + const randomIndex = Math.floor(Math.random() * notOwnedBadges.length); + return [notOwnedBadges[randomIndex]]; + } + + return allBadgesWithOwnedStatus; + }, + [badges, showRandomBadge, isMobile] + ); + + // Slice badges in arrays of two to display them in a responsive way. + const badgesSlicedInArraysOfTwo = React.useMemo( + () => { + const slicedBadges = []; + for (let i = 0; i < badgesToShow.length; i += 2) { + slicedBadges.push(badgesToShow.slice(i, i + 2)); + } + return slicedBadges; + }, + [badgesToShow] + ); + 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'} + {!hideStatusBanner && ( + Claim credits} + onActionButtonClick={onOpenProfile} /> + )} + + + {badgesSlicedInArraysOfTwo.map((badges, index) => ( + + {badges.slice(0, 2).map(badge => ( + + ))} + + ))} ); diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/RecommendationList.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/RecommendationList.js index ffba7897e690..3d24ccee7b63 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/RecommendationList.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/RecommendationList.js @@ -28,6 +28,7 @@ import { ColumnStackLayout } from '../../../../UI/Layout'; import { type GuidedLessonsRecommendation, type PlanRecommendation, + type QuickCustomizationRecommendation, } from '../../../../Utils/GDevelopServices/User'; import PreferencesContext from '../../../Preferences/PreferencesContext'; import PlanRecommendationRow from './PlanRecommendationRow'; 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..dd94859b73d8 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_PADDING } from './SectionContainer'; const iconSize = 20; const iconButtonPaddingTop = 8; @@ -40,7 +41,7 @@ export const homepageMediumMenuBarWidth = export const styles = { desktopMenu: { - paddingTop: 40, + paddingTop: SECTION_PADDING, // To align with the top of the sections paddingBottom: 10, minWidth: homepageDesktopMenuBarWidth, display: 'flex', 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/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/index.js index f0485d5a1c40..6464d6ba3537 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} /> )} {activeTab === 'get-started' && ( @@ -517,25 +517,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' - ) => void, setTakeScreenshotOnPreview: (enabled: boolean) => void, |}; @@ -393,7 +389,6 @@ export const initialPreferences = { editorStateByProject: {}, fetchPlayerTokenForPreviewAutomatically: true, previewCrashReportUploadLevel: 'exclude-javascript-code-events', - gamesListOrderBy: 'createdAt', takeScreenshotOnPreview: true, }, setLanguage: () => {}, @@ -465,9 +460,6 @@ export const initialPreferences = { setEditorStateForProject: (projectId, editorState) => {}, setFetchPlayerTokenForPreviewAutomatically: (enabled: boolean) => {}, setPreviewCrashReportUploadLevel: (level: string) => {}, - setGamesListOrderBy: ( - orderBy: 'createdAt' | '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..c8747e31fed7 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js @@ -196,7 +196,6 @@ export default class PreferencesProvider extends React.Component { setPreviewCrashReportUploadLevel: this._setPreviewCrashReportUploadLevel.bind( this ), - setGamesListOrderBy: this._setGamesListOrderBy.bind(this), setTakeScreenshotOnPreview: this._setTakeScreenshotOnPreview.bind(this), }; @@ -1008,20 +1007,6 @@ export default class PreferencesProvider extends React.Component { ); } - _setGamesListOrderBy( - newValue: 'createdAt' | 'totalSessions' | 'weeklySessions' - ) { - this.setState( - state => ({ - values: { - ...state.values, - gamesListOrderBy: newValue, - }, - }), - () => this._persistValuesToLocalStorage(this.state) - ); - } - _setTakeScreenshotOnPreview(newValue: boolean) { this.setState( 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..fca749a2135a 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,11 @@ export const useMultiplayerLobbyConfigurator = (): UseMultiplayerLobbyConfigurat await registerGame( getAuthorizationHeader, profile.id, - getDefaultRegisterGamePropertiesFromProject({ project }) + getDefaultRegisterGameProperties({ + projectId: project.getProjectUuid(), + projectName: project.getName(), + projectAuthor: project.getAuthor(), + }) ); } catch (error) { console.error( 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 }) => ( { @@ -16,7 +17,7 @@ const BackgroundText = (props: Props) => { return ( => { const authorizationHeader = await getAuthorizationHeader(); diff --git a/newIDE/app/src/Utils/UseCreateProject.js b/newIDE/app/src/Utils/UseCreateProject.js index 1a9a21f2db9c..b25b59bbad3c 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 = {| @@ -164,8 +164,10 @@ const useCreateProject = ({ await registerGame( authenticatedUser.getAuthorizationHeader, authenticatedUser.profile.id, - getDefaultRegisterGamePropertiesFromProject({ - project: currentProject, + getDefaultRegisterGameProperties({ + projectId: currentProject.getProjectUuid(), + projectName: currentProject.getName(), + projectAuthor: currentProject.getAuthor(), }) ); await onGameRegistered(); diff --git a/newIDE/app/src/Utils/UseGameAndBuildsManager.js b/newIDE/app/src/Utils/UseGameAndBuildsManager.js index 9dafb2f965a6..8fe1b620039d 100644 --- a/newIDE/app/src/Utils/UseGameAndBuildsManager.js +++ b/newIDE/app/src/Utils/UseGameAndBuildsManager.js @@ -19,17 +19,18 @@ import { replaceLeaderboardsInProject, } from '../Leaderboard/UseLeaderboardReplacer'; -export const getDefaultRegisterGamePropertiesFromProject = ({ - project, - isRemix, +export const getDefaultRegisterGameProperties = ({ + projectId, + projectName, + projectAuthor, }: {| - project: gdProject, - isRemix?: boolean, + projectId: string, + projectName: ?string, + projectAuthor: ?string, |}) => ({ - 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', }); export type GameManager = {| @@ -153,7 +154,11 @@ export const useGameManager = ({ await registerGame( getAuthorizationHeader, userId, - getDefaultRegisterGamePropertiesFromProject({ project }) + getDefaultRegisterGameProperties({ + projectId: gameId, + projectName: project.getName(), + projectAuthor: project.getAuthor(), + }) ); // We don't await for the authors update, as it is not required for publishing. diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js index 011a9cf14c58..479004602f7c 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js @@ -30,6 +30,7 @@ export const WithoutAProjectOpened = () => { onRefreshGames={action('onRefreshGames')} onOpenGameId={action('onOpenGameId')} onOpenProject={action('onOpenProject')} + canOpen={true} /> ); From e4a688f62228d87d818b3abcff311e5da9d65301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Tue, 3 Dec 2024 19:14:05 +0100 Subject: [PATCH 02/26] wip handle register/unregister/save from dashboard --- newIDE/app/src/GameDashboard/GameCard.js | 79 ++-- newIDE/app/src/GameDashboard/GamesList.js | 438 ++++++++++-------- newIDE/app/src/GameDashboard/ProjectCard.js | 6 +- newIDE/app/src/GameDashboard/UseGamesList.js | 37 +- .../src/Leaderboard/UseLeaderboardReplacer.js | 2 + .../HomePage/CreateSection/index.js | 28 +- .../EditorContainers/HomePage/index.js | 2 + .../UseMultiplayerLobbyConfigurator.js | 2 + newIDE/app/src/MainFrame/index.js | 13 + newIDE/app/src/Utils/GDevelopServices/Game.js | 7 + newIDE/app/src/Utils/UseCreateProject.js | 35 +- .../app/src/Utils/UseGameAndBuildsManager.js | 6 + 12 files changed, 418 insertions(+), 237 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GameCard.js b/newIDE/app/src/GameDashboard/GameCard.js index cebce184655c..65509c09cdf0 100644 --- a/newIDE/app/src/GameDashboard/GameCard.js +++ b/newIDE/app/src/GameDashboard/GameCard.js @@ -49,16 +49,24 @@ type Props = {| onOpenGameManager: () => void, storageProviders: Array, onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, + onUnregisterGame: () => Promise, askToCloseProject: () => Promise, + onSaveProject: () => Promise, + disabled: boolean, + canSaveProject: boolean, |}; export const GameCard = ({ - storageProviders, game, isCurrentProjectOpened, onOpenGameManager, + storageProviders, onOpenProject, + onUnregisterGame, askToCloseProject, + onSaveProject, + disabled, + canSaveProject, }: Props) => { useOnResize(useForceUpdate()); const projectsList = useProjectsListFor(game); @@ -188,15 +196,25 @@ export const GameCard = ({ : []; if (isCurrentProjectOpened) { - actions.push( - { type: 'separator' }, - { - label: i18n._(t`Close project`), - click: async () => { - await askToCloseProject(); - }, - } - ); + if (actions.length > 0) { + actions.push({ type: 'separator' }); + } + actions.push({ + label: i18n._(t`Close project`), + click: async () => { + await askToCloseProject(); + }, + }); + } else { + if (actions.length > 0) { + actions.push({ type: 'separator' }); + } + actions.push({ + label: i18n._(t`Unregister game`), + click: async () => { + await onUnregisterGame(); + }, + }); } return actions; @@ -217,25 +235,25 @@ export const GameCard = ({ ) } onClick={onOpenGameManager} + disabled={disabled} /> - {projectsList.length === 0 ? null : projectsList.length === 1 && - !isCurrentProjectOpened ? ( - Open - ) : ( - Open project - ) - } - onClick={ - isCurrentProjectOpened - ? undefined - : () => onOpenProject(projectsList[0]) - } - /> + {projectsList.length === 0 ? ( + isCurrentProjectOpened ? ( + Save + ) : ( + Save project + ) + } + onClick={onSaveProject} + buildMenuTemplate={i18n => buildContextMenu(i18n, projectsList)} + disabled={disabled || !canSaveProject} + /> + ) : null ) : ( Opened - ) : ( + ) : isWidthConstrained ? ( Open + ) : ( + Open project ) } onClick={ @@ -253,6 +273,7 @@ export const GameCard = ({ : () => onOpenProject(projectsList[0]) } buildMenuTemplate={i18n => buildContextMenu(i18n, projectsList)} + disabled={disabled} /> )} diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index cf56d7f99813..699753180199 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -1,6 +1,8 @@ // @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'; @@ -79,7 +81,7 @@ const getDashboardItemLastModifiedAt = (item: DashboardItem): number => { ); } // Then the game, if any. - return (item.game && item.game.updatedAt) || 0; + return (item.game && item.game.updatedAt * 1000) || 0; }; const lastModifiedAtSort = ( @@ -117,6 +119,7 @@ const areDashboardItemsEqual = ( }; const getDashboardItemsToDisplay = ({ + project, currentFileMetadata, allDashboardItems, searchText, @@ -124,6 +127,7 @@ const getDashboardItemsToDisplay = ({ currentPage, orderBy, }: {| + project: ?gdProject, currentFileMetadata: ?FileMetadata, allDashboardItems: Array, searchText: string, @@ -150,36 +154,46 @@ const getDashboardItemsToDisplay = ({ itemsToDisplay = searchResults.map(result => result.item); } else { // If a project is opened and no search is performed, display it first. - if (currentFileMetadata) { - const dashboardItemLinkedToOpenedFileMetadata = allDashboardItems.find( + if (project) { + const currentProjectId = project.getProjectUuid(); + const currentFileIdentifier = currentFileMetadata + ? currentFileMetadata.fileIdentifier + : null; + const dashboardItemLinkedToOpenedProject = allDashboardItems.find( dashboardItem => - dashboardItem.projectFiles && - dashboardItem.projectFiles.some( - projectFile => - projectFile.fileMetadata.gameId === currentFileMetadata.gameId && - projectFile.fileMetadata.fileIdentifier === - currentFileMetadata.fileIdentifier - ) + // 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 (dashboardItemLinkedToOpenedFileMetadata) { + if (dashboardItemLinkedToOpenedProject) { itemsToDisplay = [ - dashboardItemLinkedToOpenedFileMetadata, + dashboardItemLinkedToOpenedProject, ...itemsToDisplay.filter( item => - !areDashboardItemsEqual( - item, - dashboardItemLinkedToOpenedFileMetadata - ) + !areDashboardItemsEqual(item, dashboardItemLinkedToOpenedProject) ), ]; } else { - // Could not find the dashboard item linked to the opened project. This can happen if the project - // has been removed from the list of recent projects, for example. + // 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: currentFileMetadata, + fileMetadata, // We're not sure about the storage provider, so we leave it empty. storageProviderName: '', }, @@ -190,7 +204,15 @@ const getDashboardItemsToDisplay = ({ } } - return itemsToDisplay.slice( + const itemsWithoutUnsavedGames = itemsToDisplay.filter( + item => + // Filter out unsaved games, unless they are the opened project. + !item.game || + !item.game.unsaved || + (project && item.game.id === project.getProjectUuid()) + ); + + return itemsWithoutUnsavedGames.slice( currentPage * pageSize, (currentPage + 1) * pageSize ); @@ -204,11 +226,15 @@ type Props = {| onRefreshGames: () => Promise, onOpenGameId: (gameId: ?string) => void, onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, + onUnregisterGame: (game: Game, i18n: I18nType) => Promise, + isUpdatingGame: boolean, canOpen: boolean, onOpenNewProjectSetupDialog: () => void, onChooseProject: () => void, closeProject: () => Promise, askToCloseProject: () => Promise, + onSaveProject: () => Promise, + canSaveProject: boolean, |}; const GamesList = ({ @@ -218,12 +244,16 @@ const GamesList = ({ onRefreshGames, onOpenGameId, onOpenProject, + onUnregisterGame, + isUpdatingGame, storageProviders, canOpen, onOpenNewProjectSetupDialog, onChooseProject, closeProject, askToCloseProject, + onSaveProject, + canSaveProject, }: Props) => { const { cloudProjects, profile, onCloudProjectsChanged } = React.useContext( AuthenticatedUserContext @@ -270,6 +300,7 @@ const GamesList = ({ Array >( getDashboardItemsToDisplay({ + project, currentFileMetadata, allDashboardItems, searchText, @@ -283,6 +314,7 @@ const GamesList = ({ () => { setDisplayedDashboardItems( getDashboardItemsToDisplay({ + project, currentFileMetadata, allDashboardItems, searchText, @@ -298,18 +330,16 @@ 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 - // - opened project changes + // Refresh games to display, depending on a few parameters. React.useEffect(getDashboardItemsToDisplayDebounced, [ getDashboardItemsToDisplayDebounced, - searchText, - games, - currentPage, - orderBy, - currentFileMetadata, + 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; @@ -355,175 +385,195 @@ const GamesList = ({ ); return ( - - - - - Games - - -
- -
-
-
- - Create : Create new game - } - onClick={onOpenNewProjectSetupDialog} - icon={} - id="home-create-project-button" - /> - {canOpen && ( - Open : Open a project - } - onClick={onChooseProject} - /> - )} - -
- {allDashboardItems.length > 0 && ( - - - // $FlowFixMe - setGamesListOrderBy(value) - } - > - - - - - - - {}} - placeholder={t`Search by name`} - autoFocus="desktop" + + {({ i18n }) => ( + + + + + Games + + +
+ +
+
+
+ + Create + ) : ( + Create new game + ) + } + onClick={onOpenNewProjectSetupDialog} + icon={} + id="home-create-project-button" + disabled={isUpdatingGame} /> -
- 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" - > - - -
-
- )} - {displayedDashboardItems.length > 0 ? ( - displayedDashboardItems - .map((dashboardItem, index) => { - const game = dashboardItem.game; - if (game) { - return ( - Open + ) : ( + Open a project + ) } - game={game} - onOpenGameManager={() => { - onOpenGameId(game.id); - }} - onOpenProject={onOpenProject} - askToCloseProject={askToCloseProject} + onClick={onChooseProject} + disabled={isUpdatingGame} /> - ); - } - const projectFiles = dashboardItem.projectFiles; - if (projectFiles) { - const projectFileMetadataAndStorageProviderName = projectFiles[0]; - return ( - - onOpenProject(projectFileMetadataAndStorageProviderName) - } - lastModifiedInfo={ - lastModifiedInfoByProjectId[ - projectFileMetadataAndStorageProviderName.fileMetadata - .fileIdentifier - ] - } - isCurrentProjectOpened={ - !!projectUuid && - projectFileMetadataAndStorageProviderName.fileMetadata - .gameId === projectUuid - } - currentFileMetadata={currentFileMetadata} - closeProject={closeProject} - askToCloseProject={askToCloseProject} - onRefreshGames={refreshGamesList} + )} + + + {allDashboardItems.length > 0 && ( + + + // $FlowFixMe + setGamesListOrderBy(value) + } + > + + + - ); - } + + + + {}} + 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" + > + + + + + )} + {displayedDashboardItems.length > 0 ? ( + displayedDashboardItems + .map((dashboardItem, index) => { + const game = dashboardItem.game; + if (game) { + return ( + { + onOpenGameId(game.id); + }} + onOpenProject={onOpenProject} + onUnregisterGame={() => onUnregisterGame(game, i18n)} + disabled={isUpdatingGame} + canSaveProject={canSaveProject} + askToCloseProject={askToCloseProject} + onSaveProject={onSaveProject} + /> + ); + } + const projectFiles = dashboardItem.projectFiles; + if (projectFiles) { + const projectFileMetadataAndStorageProviderName = + projectFiles[0]; + return ( + + onOpenProject(projectFileMetadataAndStorageProviderName) + } + lastModifiedInfo={ + lastModifiedInfoByProjectId[ + projectFileMetadataAndStorageProviderName.fileMetadata + .fileIdentifier + ] + } + isCurrentProjectOpened={ + !!projectUuid && + projectFileMetadataAndStorageProviderName.fileMetadata + .gameId === projectUuid + } + currentFileMetadata={currentFileMetadata} + disabled={isUpdatingGame} + closeProject={closeProject} + askToCloseProject={askToCloseProject} + onRefreshGames={refreshGamesList} + /> + ); + } - return null; - }) - .filter(Boolean) - ) : !!searchText ? ( - - - - No game matching your search. - - - - ) : null} -
+ return null; + }) + .filter(Boolean) + ) : !!searchText ? ( + + + + No game matching your search. + + + + ) : null} + + )} +
); }; diff --git a/newIDE/app/src/GameDashboard/ProjectCard.js b/newIDE/app/src/GameDashboard/ProjectCard.js index c451f5444b2e..b8666e2841be 100644 --- a/newIDE/app/src/GameDashboard/ProjectCard.js +++ b/newIDE/app/src/GameDashboard/ProjectCard.js @@ -55,6 +55,7 @@ type Props = {| closeProject: () => Promise, askToCloseProject: () => Promise, onRefreshGames: () => Promise, + disabled: boolean, |}; const ProjectCard = ({ @@ -67,6 +68,7 @@ const ProjectCard = ({ closeProject, askToCloseProject, onRefreshGames, + disabled, }: Props) => { useOnResize(useForceUpdate()); const authenticatedUser = React.useContext(AuthenticatedUserContext); @@ -189,6 +191,8 @@ const ProjectCard = ({ projectId, projectName: fileMetadata.name, projectAuthor: username, + // A project is always saved when appearing in the list of recent projects. + isProjectSaved: true, }) ); await onRefreshGames(); @@ -289,7 +293,7 @@ const ProjectCard = ({ Opened diff --git a/newIDE/app/src/GameDashboard/UseGamesList.js b/newIDE/app/src/GameDashboard/UseGamesList.js index 2691aea1508a..be677b0f0f5d 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 => { @@ -61,11 +66,41 @@ const useGamesList = (): GamesList => { [games] ); + const markGameAsSavedIfRelevant = React.useCallback( + async (gameId: string) => { + console.log('markGameAsSavedIfRelevant', gameId, games, firebaseUser); + if (!games || !firebaseUser) return; + const currentOpenedGame = games && games.find(game => game.id === gameId); + + console.log('currentOpenedGame', currentOpenedGame); + + if (!currentOpenedGame || !currentOpenedGame.unsaved) return; + + try { + const updatedGame = await updateGame( + getAuthorizationHeader, + firebaseUser.uid, + currentOpenedGame.id, + { + unsaved: false, + } + ); + console.log('Game marked as saved:', updatedGame); + 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/Leaderboard/UseLeaderboardReplacer.js b/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js index 238174832a26..2eb742aab418 100644 --- a/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js +++ b/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js @@ -244,6 +244,8 @@ export const replaceLeaderboardsInProject = async ({ projectId: project.getProjectUuid(), projectName: project.getName(), projectAuthor: project.getAuthor(), + // Assume the project is not saved at this stage. + isProjectSaved: true, }) ); } catch (error) { diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index 2b15af8568d8..0e93a70a88b7 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -94,6 +94,8 @@ type Props = {| setCurrentTab: GameDetailsTab => void, onOpenNewProjectSetupDialog: () => void, onChooseProject: () => void, + onSaveProject: () => Promise, + canSaveProject: boolean, |}; const CreateSection = ({ @@ -119,6 +121,8 @@ const CreateSection = ({ setCurrentTab, onOpenNewProjectSetupDialog, onChooseProject, + onSaveProject, + canSaveProject, }: Props) => { const authenticatedUser = React.useContext(AuthenticatedUserContext); const { @@ -133,6 +137,7 @@ const CreateSection = ({ gameUnregisterErrorText, setGameUnregisterErrorText, ] = React.useState(null); + const [isUpdatingGame, setIsUpdatingGame] = React.useState(false); const [showAllGameTemplates, setShowAllGameTemplates] = React.useState(false); const { routeArguments, removeRouteArguments } = React.useContext( RouterContext @@ -176,20 +181,23 @@ const CreateSection = ({ ); const unregisterGame = React.useCallback( - async (i18n: I18nType) => { - if (!profile || !openedGame) return; + async (game: Game, i18n: I18nType) => { + if (!profile) 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.`, + 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; setGameUnregisterErrorText(null); + setIsUpdatingGame(true); try { - await deleteGame(getAuthorizationHeader, id, openedGame.id); - setOpenedGameId(null); + await deleteGame(getAuthorizationHeader, id, game.id); + if (openedGame && openedGame.id === game.id) { + setOpenedGameId(null); + } } catch (error) { console.error('Unable to delete the game:', error); const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( @@ -214,7 +222,10 @@ const CreateSection = ({ errorId: 'game-dashboard-unregister-game', }); } + } finally { + setIsUpdatingGame(false); } + onRefreshGames(); }, [ @@ -281,7 +292,8 @@ const CreateSection = ({ game={openedGame} onBack={onBack} onGameUpdated={onGameUpdated} - onUnregisterGame={unregisterGame} + isUpdatingGame={isUpdatingGame} + onUnregisterGame={() => unregisterGame(openedGame, i18n)} gameUnregisterErrorText={gameUnregisterErrorText} /> @@ -365,12 +377,16 @@ const CreateSection = ({ onRefreshGames={onRefreshGames} onOpenGameId={setOpenedGameId} onOpenProject={onOpenProject} + isUpdatingGame={isUpdatingGame} + onUnregisterGame={unregisterGame} canOpen={canOpen} onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog} onChooseProject={onChooseProject} currentFileMetadata={currentFileMetadata} closeProject={closeProject} askToCloseProject={askToCloseProject} + onSaveProject={onSaveProject} + canSaveProject={canSaveProject} /> {/* Check if looks ok */} {isMobile && limits && hasTooManyCloudProjects && ( diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/index.js index 6464d6ba3537..ebe961851086 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/index.js @@ -502,6 +502,8 @@ export const HomePage = React.memo( i18n={i18n} onOpenNewProjectSetupDialog={onOpenNewProjectSetupDialog} onChooseProject={onChooseProject} + onSaveProject={onSave} + canSaveProject={canSave} /> )} {activeTab === 'get-started' && ( diff --git a/newIDE/app/src/MainFrame/UseMultiplayerLobbyConfigurator.js b/newIDE/app/src/MainFrame/UseMultiplayerLobbyConfigurator.js index fca749a2135a..d7785785d3d8 100644 --- a/newIDE/app/src/MainFrame/UseMultiplayerLobbyConfigurator.js +++ b/newIDE/app/src/MainFrame/UseMultiplayerLobbyConfigurator.js @@ -50,6 +50,8 @@ export const useMultiplayerLobbyConfigurator = (): UseMultiplayerLobbyConfigurat projectId: project.getProjectUuid(), projectName: project.getName(), projectAuthor: project.getAuthor(), + // Assume the project is not saved at this stage. + isProjectSaved: false, }) ); } catch (error) { diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index d3291383568d..7582d18ca737 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -1135,6 +1135,7 @@ const MainFrame = (props: Props) => { setIsProjectOpening(true); }, getStorageProviderOperations, + getStorageProvider, afterCreatingProject: async ({ project, editorTabs, @@ -2647,6 +2648,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 +2723,7 @@ const MainFrame = (props: Props) => { currentlyRunningInAppTutorial, showAlert, showConfirmation, + gamesList, ] ); @@ -2836,6 +2842,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 +2925,7 @@ const MainFrame = (props: Props) => { showAlert, showConfirmation, checkedOutVersionStatus, + gamesList, ] ); diff --git a/newIDE/app/src/Utils/GDevelopServices/Game.js b/newIDE/app/src/Utils/GDevelopServices/Game.js index de2e7294e901..4f6651d168d4 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Game.js +++ b/newIDE/app/src/Utils/GDevelopServices/Game.js @@ -69,6 +69,7 @@ export type Game = {| playWithKeyboard: boolean, playWithMobile: boolean, playWithGamepad: boolean, + unsaved?: boolean, |}; export type GameUpdatePayload = {| @@ -87,6 +88,7 @@ export type GameUpdatePayload = {| acceptsBuildComments?: boolean, acceptsGameComments?: boolean, displayAdsOnGamePage?: boolean, + unsaved?: boolean, |}; export type GameCategory = { @@ -291,11 +293,13 @@ export const registerGame = async ( gameName, authorName, templateSlug, + unsaved, }: {| gameId: string, gameName: string, authorName: string, templateSlug?: string, + unsaved?: boolean, |} ): Promise => { const authorizationHeader = await getAuthorizationHeader(); @@ -306,6 +310,7 @@ export const registerGame = async ( gameName, authorName, templateSlug, + unsaved, }, { params: { @@ -340,6 +345,7 @@ export const updateGame = async ( acceptsBuildComments, acceptsGameComments, displayAdsOnGamePage, + unsaved, }: GameUpdatePayload ): Promise => { const authorizationHeader = await getAuthorizationHeader(); @@ -361,6 +367,7 @@ export const updateGame = async ( acceptsBuildComments, acceptsGameComments, displayAdsOnGamePage, + unsaved, }, { params: { diff --git a/newIDE/app/src/Utils/UseCreateProject.js b/newIDE/app/src/Utils/UseCreateProject.js index b25b59bbad3c..8893a4f7653c 100644 --- a/newIDE/app/src/Utils/UseCreateProject.js +++ b/newIDE/app/src/Utils/UseCreateProject.js @@ -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, @@ -168,6 +170,8 @@ const useCreateProject = ({ projectId: currentProject.getProjectUuid(), projectName: currentProject.getName(), projectAuthor: currentProject.getAuthor(), + // Project is not saved yet here. + isProjectSaved: false, }) ); await onGameRegistered(); @@ -183,6 +187,8 @@ const useCreateProject = ({ const destinationStorageProviderOperations = getStorageProviderOperations( newProjectSetup.storageProvider ); + const newStorageProvider = getStorageProvider(); + const storageProviderInternalName = newStorageProvider.internalName; const { onSaveProjectAs } = destinationStorageProviderOperations; @@ -221,13 +227,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, @@ -258,6 +280,7 @@ const useCreateProject = ({ }, [ authenticatedUser, + getStorageProvider, getStorageProviderOperations, loadFromProject, onError, diff --git a/newIDE/app/src/Utils/UseGameAndBuildsManager.js b/newIDE/app/src/Utils/UseGameAndBuildsManager.js index 8fe1b620039d..169d453a3c38 100644 --- a/newIDE/app/src/Utils/UseGameAndBuildsManager.js +++ b/newIDE/app/src/Utils/UseGameAndBuildsManager.js @@ -23,14 +23,17 @@ export const getDefaultRegisterGameProperties = ({ projectId, projectName, projectAuthor, + isProjectSaved, }: {| projectId: string, projectName: ?string, projectAuthor: ?string, + isProjectSaved: boolean, |}) => ({ gameId: projectId, authorName: projectAuthor || 'Unspecified publisher', gameName: projectName || 'Untitled game', + unsaved: !isProjectSaved, }); export type GameManager = {| @@ -158,6 +161,9 @@ export const useGameManager = ({ 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. + isProjectSaved: false, }) ); From fa8547f616542b92247fdf77871be9150b819de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 4 Dec 2024 10:41:39 +0100 Subject: [PATCH 03/26] Fix refreshing games and disable buttons --- newIDE/app/src/GameDashboard/GamesList.js | 2 +- newIDE/app/src/GameDashboard/PublicGamePropertiesDialog.js | 1 + newIDE/app/src/GameDashboard/index.js | 4 +++- .../EditorContainers/HomePage/CreateSection/index.js | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 699753180199..775a3a844edf 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -350,7 +350,7 @@ const GamesList = ({ if (isRefreshing) return; try { setIsRefreshing(true); - await Promise.all([onCloudProjectsChanged, onRefreshGames]); + await Promise.all([onCloudProjectsChanged(), onRefreshGames()]); } finally { // Wait a bit to avoid spam as we don't have a "loading" state. setTimeout(() => setIsRefreshing(false), 2000); diff --git a/newIDE/app/src/GameDashboard/PublicGamePropertiesDialog.js b/newIDE/app/src/GameDashboard/PublicGamePropertiesDialog.js index 627fb110198a..cfe3e0c7aab2 100644 --- a/newIDE/app/src/GameDashboard/PublicGamePropertiesDialog.js +++ b/newIDE/app/src/GameDashboard/PublicGamePropertiesDialog.js @@ -258,6 +258,7 @@ export const PublicGamePropertiesDialog = ({ onClick={onUnregisterGame} label={Unregister game} leftIcon={} + disabled={isLoading} /> diff --git a/newIDE/app/src/GameDashboard/index.js b/newIDE/app/src/GameDashboard/index.js index 077090773fa3..b50b131788f8 100644 --- a/newIDE/app/src/GameDashboard/index.js +++ b/newIDE/app/src/GameDashboard/index.js @@ -84,6 +84,7 @@ type Props = {| currentView: GameDetailsTab, setCurrentView: GameDetailsTab => void, onBack: () => void, + disabled: boolean, |}; const GameDashboard = ({ @@ -104,6 +105,7 @@ const GameDashboard = ({ currentView, setCurrentView, onBack, + disabled, }: Props) => { const [ gameDetailsDialogOpen, @@ -578,7 +580,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) { diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index 0e93a70a88b7..7d678ccf0d4c 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -292,7 +292,7 @@ const CreateSection = ({ game={openedGame} onBack={onBack} onGameUpdated={onGameUpdated} - isUpdatingGame={isUpdatingGame} + disabled={isUpdatingGame} onUnregisterGame={() => unregisterGame(openedGame, i18n)} gameUnregisterErrorText={gameUnregisterErrorText} /> From c2d1b3b4d54c3b92da9b4144e62bb48b4df3dec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 4 Dec 2024 10:45:53 +0100 Subject: [PATCH 04/26] Hide refresh when no games --- newIDE/app/src/GameDashboard/GamesList.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 775a3a844edf..42bebb339da8 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -393,16 +393,18 @@ const GamesList = ({ Games - -
- -
-
+ {allDashboardItems.length > 0 && ( + +
+ +
+
+ )} Date: Wed, 4 Dec 2024 16:09:17 +0100 Subject: [PATCH 05/26] Fix stories --- .../app/src/AssetStore/ExampleStore/index.js | 26 +++ newIDE/app/src/GameDashboard/GameCard.js | 4 +- newIDE/app/src/GameDashboard/GamesList.js | 3 +- newIDE/app/src/GameDashboard/ProjectCard.js | 17 +- newIDE/app/src/GameDashboard/UseGamesList.js | 2 - .../HomePage/CreateSection/ProjectFileList.js | 19 +- .../HomePage/CreateSection/index.js | 122 ++++++------ .../HomePage/CreateSection/utils.js | 3 + .../ProjectCreation/NewProjectSetupDialog.js | 2 +- .../GDevelopServicesTestData/index.js | 24 +++ .../GameDashboard/GameCard.stories.js | 72 ++++++- .../GameDashboard/GameDashboard.stories.js | 1 + .../GameDashboard/GamesList.stories.js | 188 ++++++++++++++++-- .../Monetization/UserEarnings.stories.js | 16 +- .../GameDashboard/ProjectCard.stories.js | 143 +++++++++++++ .../HomePage/HomePage.stories.js | 1 + .../ProjectManager/ProjectManager.stories.js | 2 + 17 files changed, 553 insertions(+), 92 deletions(-) create mode 100644 newIDE/app/src/stories/componentStories/GameDashboard/ProjectCard.stories.js diff --git a/newIDE/app/src/AssetStore/ExampleStore/index.js b/newIDE/app/src/AssetStore/ExampleStore/index.js index 7e3ae7ddc40e..a08942b41e1c 100644 --- a/newIDE/app/src/AssetStore/ExampleStore/index.js +++ b/newIDE/app/src/AssetStore/ExampleStore/index.js @@ -18,6 +18,7 @@ import GridList from '@material-ui/core/GridList'; import { getExampleAndTemplateTiles } from '../../MainFrame/EditorContainers/HomePage/CreateSection/utils'; import BackgroundText from '../../UI/BackgroundText'; import { ColumnStackLayout } from '../../UI/Layout'; +import { isStartingPointExampleShortHeader } from '../../ProjectCreation/EmptyAndStartingPointProjects'; const styles = { grid: { @@ -39,11 +40,24 @@ const gameFilter = ( return true; }; +const noStartingPointFilter = ( + item: PrivateGameTemplateListingData | ExampleShortHeader +) => { + 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, @@ -57,6 +71,7 @@ const ExampleStore = ({ onSelectPrivateGameTemplateListingData, i18n, onlyShowGames, + hideStartingPoints, columnsCount, rowToInsert, hideSearch, @@ -133,6 +148,11 @@ const ExampleStore = ({ privateGameTemplateListingData => !onlyShowGames || gameFilter(privateGameTemplateListingData) ) + .filter( + privateGameTemplateListingData => + !hideStartingPoints || + noStartingPointFilter(privateGameTemplateListingData) + ) : [], exampleShortHeaders: exampleShortHeadersSearchResults ? exampleShortHeadersSearchResults @@ -141,6 +161,11 @@ const ExampleStore = ({ exampleShortHeader => !onlyShowGames || gameFilter(exampleShortHeader) ) + .filter( + exampleShortHeader => + !hideStartingPoints || + noStartingPointFilter(exampleShortHeader) + ) : [], onSelectPrivateGameTemplateListingData: privateGameTemplateListingData => { sendGameTemplateInformationOpened({ @@ -169,6 +194,7 @@ const ExampleStore = ({ onSelectExampleShortHeader, i18n, onlyShowGames, + hideStartingPoints, ] ); diff --git a/newIDE/app/src/GameDashboard/GameCard.js b/newIDE/app/src/GameDashboard/GameCard.js index 65509c09cdf0..d6256c736794 100644 --- a/newIDE/app/src/GameDashboard/GameCard.js +++ b/newIDE/app/src/GameDashboard/GameCard.js @@ -56,7 +56,7 @@ type Props = {| canSaveProject: boolean, |}; -export const GameCard = ({ +const GameCard = ({ game, isCurrentProjectOpened, onOpenGameManager, @@ -331,3 +331,5 @@ export const GameCard = ({ ); }; + +export default GameCard; diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 42bebb339da8..d944c01185a8 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -5,7 +5,7 @@ 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 GameCard from './GameCard'; import { ColumnStackLayout, LineStackLayout, @@ -464,7 +464,6 @@ const GamesList = ({ // Search is triggered on each search text change onRequestSearch={() => {}} placeholder={t`Search by name`} - autoFocus="desktop" /> { + const result = await showConfirmation({ + 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; + removeRecentProjectFile(file); + }, + [removeRecentProjectFile, showConfirmation] + ); + const onRegisterProject = React.useCallback( async () => { const projectId = fileMetadata.gameId; @@ -263,7 +278,7 @@ const ProjectCard = ({ }, { label: i18n._(t`Remove from list`), - click: () => removeRecentProjectFile(file), + click: () => onRemoveRecentProjectFile(file), } ); } diff --git a/newIDE/app/src/GameDashboard/UseGamesList.js b/newIDE/app/src/GameDashboard/UseGamesList.js index be677b0f0f5d..4f3ba114f7e4 100644 --- a/newIDE/app/src/GameDashboard/UseGamesList.js +++ b/newIDE/app/src/GameDashboard/UseGamesList.js @@ -72,8 +72,6 @@ const useGamesList = (): GamesList => { if (!games || !firebaseUser) return; const currentOpenedGame = games && games.find(game => game.id === gameId); - console.log('currentOpenedGame', currentOpenedGame); - if (!currentOpenedGame || !currentOpenedGame.unsaved) return; try { diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js index 2757c7d718be..56cd01f429ed 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/ProjectFileList.js @@ -183,6 +183,21 @@ const ProjectFileList = ({ } }; + const onRemoveRecentProjectFile = React.useCallback( + async (file: FileMetadataAndStorageProviderName) => { + const result = await showConfirmation({ + 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; + removeRecentProjectFile(file); + }, + [removeRecentProjectFile, showConfirmation] + ); + const buildContextMenu = ( i18n: I18nType, file: ?FileMetadataAndStorageProviderName @@ -206,14 +221,14 @@ const ProjectFileList = ({ }, { label: i18n._(t`Remove from list`), - click: () => removeRecentProjectFile(file), + click: () => onRemoveRecentProjectFile(file), }, ] ); } else { actions.push({ label: i18n._(t`Remove from list`), - click: () => removeRecentProjectFile(file), + click: () => onRemoveRecentProjectFile(file), }); } diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index 7d678ccf0d4c..114973162980 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -402,68 +402,74 @@ const CreateSection = ({ } /> )} - {(!games || games.length === 0) && - !project && - quickCustomizationRecommendation && ( - - - + {quickCustomizationRecommendation && ( + + + + {!games || games.length === 0 ? ( Publish your first game - - - { - const projectIsClosed = await askToCloseProject(); - if (!projectIsClosed) { - return; - } - - const newProjectSetup: NewProjectSetup = { - storageProvider: UrlStorageProvider, - saveAsLocation: null, - openQuickCustomizationDialog: true, - }; - onCreateProjectFromExample( - exampleShortHeader, - newProjectSetup, - i18n - ); - }} - quickCustomizationRecommendation={ - quickCustomizationRecommendation + ) : ( + Publish a game in 1 minute + )} + + + { + const projectIsClosed = await askToCloseProject(); + if (!projectIsClosed) { + return; } - /> - - - Remix an existing game - - setShowAllGameTemplates(true)} - label={ - isMobile ? ( - Browse - ) : ( - Browse all templates - ) - } - leftIcon={} - /> - - + + )} + {(!games || games.length === 0) && !project && ( + + + + Remix an existing game + + setShowAllGameTemplates(true)} + label={ + isMobile ? ( + Browse + ) : ( + Browse all templates + ) } - i18n={i18n} - columnsCount={getExampleItemsColumns( - windowSize, - isLandscape - )} - hideSearch - onlyShowGames + leftIcon={} /> - - )} + + + + )} ) : gamesFetchingError ? ( diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js index c9bbd5f4b926..f6054544ae12 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js @@ -111,6 +111,9 @@ export const useProjectsListFor = (game: ?Game) => { file => !game || (file.fileMetadata && file.fileMetadata.gameId === game.id) ); + console.log(projectFiles); + console.log(getRecentProjectFiles()); + if (cloudProjects) { projectFiles = projectFiles.concat( transformCloudProjectsIntoFileMetadataWithStorageProviderName( 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/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/GameDashboard/GameCard.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GameCard.stories.js index 5429fdaca43a..f9bddcaa8045 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GameCard.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GameCard.stories.js @@ -4,10 +4,11 @@ import * as React from 'react'; import { action } from '@storybook/addon-actions'; import paperDecorator from '../../PaperDecorator'; -import { GameCard } from '../../../GameDashboard/GameCard'; +import GameCard from '../../../GameDashboard/GameCard'; import { fakeSilverAuthenticatedUser, game1, + game2, } from '../../../fixtures/GDevelopServicesTestData'; import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; import CloudStorageProvider from '../../../ProjectsStorage/CloudStorageProvider'; @@ -18,26 +19,87 @@ export default { decorators: [paperDecorator], }; -export const DefaultGameCard = () => ( +export const UnpublishedGame = () => ( + + + +); + +export const PublishedGame = () => ( + + + +); + +export const CurrentlyOpened = () => ( + + + +); + +export const Saving = () => ( ); -export const DefaultCurrentlyEditedCard = () => ( +export const Disabled = () => ( ); diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js index 152c8d2a1457..c8c4f59b8597 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js @@ -341,6 +341,7 @@ export const Default = ({ } gameUnregisterErrorText={gameUnregisterErrorText} closeProject={action('closeProject')} + disabled={false} /> diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js index 479004602f7c..639054b1af37 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,18 +27,175 @@ export default { decorators: [paperDecorator], }; -export const WithoutAProjectOpened = () => { +export const NoGamesOrProjects = () => { + const projectFiles = []; + + const preferences: Preferences = { + ...initialPreferences, + values: { + ...initialPreferences.values, + recentProjectFiles: projectFiles, + }, + getRecentProjectFiles: () => projectFiles, + }; + + return ( + + + + + + ); +}; + +export const WithOnlyGames = () => { + const projectFiles = [ + { + ...fakeFileMetadataAndStorageProviderNameForLocalProject, + fileMetadata: { + ...fakeFileMetadataAndStorageProviderNameForLocalProject.fileMetadata, + gameId: game1.id, + }, + }, + ]; + + const preferences: Preferences = { + ...initialPreferences, + values: { + ...initialPreferences.values, + recentProjectFiles: projectFiles, + }, + getRecentProjectFiles: () => projectFiles, + }; + + return ( + + + + + + ); +}; + +export const WithOnlyProjects = () => { + const projectFiles = [ + fakeFileMetadataAndStorageProviderNameForCloudProject, + fakeFileMetadataAndStorageProviderNameForLocalProject, + ]; + + const preferences: Preferences = { + ...initialPreferences, + values: { + ...initialPreferences.values, + recentProjectFiles: projectFiles, + }, + getRecentProjectFiles: () => projectFiles, + }; + + 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, + }; + return ( - - - + + + + + ); }; diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarnings.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarnings.stories.js index 260279bcf3af..6e62434f1835 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarnings.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarnings.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/GameDashboard/ProjectCard.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/ProjectCard.stories.js new file mode 100644 index 000000000000..cbf34052669a --- /dev/null +++ b/newIDE/app/src/stories/componentStories/GameDashboard/ProjectCard.stories.js @@ -0,0 +1,143 @@ +// @flow + +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; + +import paperDecorator from '../../PaperDecorator'; +import ProjectCard from '../../../GameDashboard/ProjectCard'; +import { + fakeFileMetadataAndStorageProviderNameForCloudProject, + fakeFileMetadataAndStorageProviderNameForLocalProject, + fakeSilverAuthenticatedUser, +} from '../../../fixtures/GDevelopServicesTestData'; +import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; +import CloudStorageProvider from '../../../ProjectsStorage/CloudStorageProvider'; +import LocalFileStorageProvider from '../../../ProjectsStorage/LocalFileStorageProvider'; + +export default { + title: 'GameDashboard/ProjectCard', + component: ProjectCard, + decorators: [paperDecorator], +}; + +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/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')} From b4e981bd1712d0269ab786fae6b0ad0302b19735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:40:05 +0100 Subject: [PATCH 06/26] More responsive improvements --- newIDE/app/src/GameDashboard/GameCard.js | 9 ++- newIDE/app/src/GameDashboard/ProjectCard.js | 8 ++- .../src/GameDashboard/Wallet/WalletWidget.js | 19 ++++- .../GameDashboard/Widgets/TotalPlaysWidget.js | 5 +- .../HomePage/CreateSection/index.js | 69 +++++++++++++++---- .../HomePage/GetStartedSection/EarnBadges.js | 8 ++- 6 files changed, 93 insertions(+), 25 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GameCard.js b/newIDE/app/src/GameDashboard/GameCard.js index d6256c736794..2887c0aaeba5 100644 --- a/newIDE/app/src/GameDashboard/GameCard.js +++ b/newIDE/app/src/GameDashboard/GameCard.js @@ -220,10 +220,13 @@ const GameCard = ({ return actions; }; + // Empty full width button on mobile to make sure the buttons are aligned + const OpenButtonPlacerHolder = () =>
; + const renderButtons = ({ fullWidth }: { fullWidth: boolean }) => { return ( -
- +
+ buildContextMenu(i18n, projectsList)} disabled={disabled || !canSaveProject} /> + ) : fullWidth ? ( + ) : null ) : (
; + const renderButtons = ({ fullWidth }: { fullWidth: boolean }) => { return ( -
- +
+ + {fullWidth ? : null} void, fullWidth?: boolean, showRandomBadge?: boolean, + showAllBadges?: boolean, |}; -const WalletWidget = ({ onOpenProfile, fullWidth, showRandomBadge }: Props) => { +const WalletWidget = ({ + onOpenProfile, + fullWidth, + showRandomBadge, + showAllBadges, +}: Props) => { const { profile, limits, @@ -28,6 +35,7 @@ const WalletWidget = ({ onOpenProfile, fullWidth, showRandomBadge }: Props) => { onOpenCreateAccountDialog, } = React.useContext(AuthenticatedUserContext); const creditsAvailable = limits ? limits.credits.userBalance.amount : 0; + const { isMobile, isMediumScreen } = useResponsiveWindowSize(); return ( { {hasMissingBadges(badges, achievements) && ( Claim in profile} + label={ + isMobile || isMediumScreen ? ( + Claim + ) : ( + Claim in profile + ) + } onClick={profile ? onOpenProfile : onOpenCreateAccountDialog} /> )} @@ -54,6 +68,7 @@ const WalletWidget = ({ onOpenProfile, fullWidth, showRandomBadge }: Props) => { badges={badges} onOpenProfile={onOpenProfile} showRandomBadge={showRandomBadge} + showAllBadges={showAllBadges} hideStatusBanner /> diff --git a/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js b/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js index 53c8d9afb90b..97719fb5c06b 100644 --- a/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js @@ -25,9 +25,10 @@ export const formatPlays = (plays: number) => { type Props = {| games: Array, + fullWidth?: boolean, |}; -const TotalPlaysWidget = ({ games }: Props) => { +const TotalPlaysWidget = ({ games, fullWidth }: Props) => { const theme = React.useContext(GDevelopThemeContext); const { @@ -60,7 +61,7 @@ const TotalPlaysWidget = ({ games }: Props) => { return ( Total plays} minHeight="small" > diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index 114973162980..4a9bb2b2df83 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -139,6 +139,10 @@ const CreateSection = ({ ] = React.useState(null); const [isUpdatingGame, setIsUpdatingGame] = React.useState(false); const [showAllGameTemplates, setShowAllGameTemplates] = React.useState(false); + const [ + showPerformanceDashboard, + setShowPerformanceDashboard, + ] = React.useState(false); const { routeArguments, removeRouteArguments } = React.useContext( RouterContext ); @@ -160,6 +164,10 @@ const CreateSection = ({ const hasTooManyCloudProjects = checkIfHasTooManyCloudProjects( authenticatedUser ); + const hidePerformanceDashboard = + !!limits && + !!limits.capabilities.classrooms && + limits.capabilities.classrooms.hideSocials; React.useEffect( () => { @@ -320,6 +328,32 @@ const CreateSection = ({ ); } + if (showPerformanceDashboard) { + return ( + setShowPerformanceDashboard(false)} + flexBody + > + + + + Performance Dashboard + + + + + + + + + + ); + } + return ( {({ i18n }) => ( @@ -349,26 +383,33 @@ const CreateSection = ({ {!!profile || loginState === 'done' ? ( - {games && games.length !== 0 ? ( + {hidePerformanceDashboard ? null : ( - + Performance Dashboard - - - - - setShowPerformanceDashboard(true)} + label={See more} + leftIcon={} /> - + + {games && games.length !== 0 ? ( + + + + + + ) : ( + + + + )} - ) : ( - - - )} void, hideStatusBanner?: boolean, showRandomBadge?: boolean, + showAllBadges?: boolean, |}; export const EarnBadges = ({ @@ -169,6 +170,7 @@ export const EarnBadges = ({ onOpenProfile, hideStatusBanner, showRandomBadge, + showAllBadges, }: Props) => { const { isMobile } = useResponsiveWindowSize(); const badgesToShow = React.useMemo( @@ -181,8 +183,8 @@ export const EarnBadges = ({ badge => !badge.hasThisBadge ); - // Only show 1 badge on mobile to avoid taking too much space. - if (showRandomBadge || isMobile) { + // If on mobile, and not forcing all badges, show only 1 badge to avoid taking too much space. + if (showRandomBadge || (isMobile && !showAllBadges)) { if (notOwnedBadges.length === 0) { const randomIndex = Math.floor( Math.random() * allBadgesWithOwnedStatus.length @@ -196,7 +198,7 @@ export const EarnBadges = ({ return allBadgesWithOwnedStatus; }, - [badges, showRandomBadge, isMobile] + [badges, showRandomBadge, isMobile, showAllBadges] ); // Slice badges in arrays of two to display them in a responsive way. From 2c7dd78ea587d1488a1941a0a278ed8c50553c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:49:35 +0100 Subject: [PATCH 07/26] Fix more buttons --- newIDE/app/src/GameDashboard/ProjectCard.js | 46 +++++++++++---------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/newIDE/app/src/GameDashboard/ProjectCard.js b/newIDE/app/src/GameDashboard/ProjectCard.js index 3de861e8167e..bcac210db086 100644 --- a/newIDE/app/src/GameDashboard/ProjectCard.js +++ b/newIDE/app/src/GameDashboard/ProjectCard.js @@ -80,7 +80,7 @@ const ProjectCard = ({ showAlert, } = useAlertDialog(); const fileMetadata = projectFileMetadataAndStorageProviderName.fileMetadata; - const projectName = fileMetadata.name || 'Unknown project'; + const projectName = fileMetadata.name; const storageProvider = getStorageProviderByInternalName( storageProviders, projectFileMetadataAndStorageProviderName.storageProviderName @@ -250,12 +250,14 @@ const ProjectCard = ({ ): Array => { if (!file) return []; - const actions = [ - { + const actions = []; + + if (authenticatedUser.profile) { + actions.push({ label: i18n._(t`Register the game online`), click: () => onRegisterProject(), - }, - ]; + }); + } if (file.storageProviderName === 'Cloud') { actions.push({ @@ -272,30 +274,30 @@ const ProjectCard = ({ // Don't allow removing project if opened, as it would not result in any change in the list. if (!isCurrentProjectOpened) { - actions.push( - { + if (actions.length > 0) { + actions.push({ type: 'separator', - }, - { - label: i18n._(t`Remove from list`), - click: () => onRemoveRecentProjectFile(file), - } - ); + }); + } + actions.push({ + label: i18n._(t`Remove from list`), + click: () => onRemoveRecentProjectFile(file), + }); } } if (isCurrentProjectOpened) { - actions.push( - { + if (actions.length > 0) { + actions.push({ type: 'separator', + }); + } + actions.push({ + label: i18n._(t`Close project`), + click: async () => { + await askToCloseProject(); }, - { - label: i18n._(t`Close project`), - click: async () => { - await askToCloseProject(); - }, - } - ); + }); } return actions; From ab4e9c83d07109ffadb618573a13c18d4e9b980b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:10:57 +0100 Subject: [PATCH 08/26] Fix saved status & buttons --- newIDE/app/src/GameDashboard/GameCard.js | 43 ++- newIDE/app/src/GameDashboard/GamesList.js | 2 +- newIDE/app/src/GameDashboard/ProjectCard.js | 7 +- newIDE/app/src/GameDashboard/UseGamesList.js | 7 +- .../src/Leaderboard/UseLeaderboardReplacer.js | 2 +- .../HomePage/BuildSection/index.js | 318 ------------------ .../HomePage/CreateSection/index.js | 13 +- .../HomePage/CreateSection/utils.js | 3 - newIDE/app/src/Utils/GDevelopServices/Game.js | 16 +- newIDE/app/src/Utils/UseCreateProject.js | 7 +- .../app/src/Utils/UseGameAndBuildsManager.js | 2 +- 11 files changed, 63 insertions(+), 357 deletions(-) delete mode 100644 newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js diff --git a/newIDE/app/src/GameDashboard/GameCard.js b/newIDE/app/src/GameDashboard/GameCard.js index 2887c0aaeba5..ece70677bb1c 100644 --- a/newIDE/app/src/GameDashboard/GameCard.js +++ b/newIDE/app/src/GameDashboard/GameCard.js @@ -227,19 +227,36 @@ const GameCard = ({ return (
- Manage - ) : ( - Manage game - ) - } - onClick={onOpenGameManager} - disabled={disabled} - /> + {projectsList.length > 0 ? ( + Manage + ) : ( + Manage game + ) + } + onClick={onOpenGameManager} + disabled={disabled} + /> + ) : ( + Manage + ) : ( + Manage game + ) + } + onClick={onOpenGameManager} + buildMenuTemplate={i18n => buildContextMenu(i18n, projectsList)} + disabled={disabled} + /> + )} {projectsList.length === 0 ? ( isCurrentProjectOpened ? ( // Filter out unsaved games, unless they are the opened project. !item.game || - !item.game.unsaved || + item.game.savedStatus !== 'draft' || (project && item.game.id === project.getProjectUuid()) ); diff --git a/newIDE/app/src/GameDashboard/ProjectCard.js b/newIDE/app/src/GameDashboard/ProjectCard.js index bcac210db086..b46396524521 100644 --- a/newIDE/app/src/GameDashboard/ProjectCard.js +++ b/newIDE/app/src/GameDashboard/ProjectCard.js @@ -73,7 +73,8 @@ const ProjectCard = ({ useOnResize(useForceUpdate()); const authenticatedUser = React.useContext(AuthenticatedUserContext); const { removeRecentProjectFile } = React.useContext(PreferencesContext); - const { isMobile } = useResponsiveWindowSize(); + const { isMobile, windowSize } = useResponsiveWindowSize(); + const isWidthConstrained = windowSize === 'small' || windowSize === 'medium'; const { showDeleteConfirmation, showConfirmation, @@ -318,8 +319,10 @@ const ProjectCard = ({ label={ isCurrentProjectOpened ? ( Opened - ) : ( + ) : isWidthConstrained ? ( Open + ) : ( + Open Project ) } onClick={isCurrentProjectOpened ? undefined : onOpenProject} diff --git a/newIDE/app/src/GameDashboard/UseGamesList.js b/newIDE/app/src/GameDashboard/UseGamesList.js index 4f3ba114f7e4..6232b2e5c721 100644 --- a/newIDE/app/src/GameDashboard/UseGamesList.js +++ b/newIDE/app/src/GameDashboard/UseGamesList.js @@ -68,11 +68,11 @@ const useGamesList = (): GamesList => { const markGameAsSavedIfRelevant = React.useCallback( async (gameId: string) => { - console.log('markGameAsSavedIfRelevant', gameId, games, firebaseUser); if (!games || !firebaseUser) return; const currentOpenedGame = games && games.find(game => game.id === gameId); - if (!currentOpenedGame || !currentOpenedGame.unsaved) return; + if (!currentOpenedGame || currentOpenedGame.savedStatus !== 'draft') + return; try { const updatedGame = await updateGame( @@ -80,10 +80,9 @@ const useGamesList = (): GamesList => { firebaseUser.uid, currentOpenedGame.id, { - unsaved: false, + savedStatus: 'saved', } ); - console.log('Game marked as saved:', updatedGame); onGameUpdated(updatedGame); } catch (error) { // Catch error, we'll try again later. diff --git a/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js b/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js index 2eb742aab418..b04ff8ee2f98 100644 --- a/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js +++ b/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js @@ -245,7 +245,7 @@ export const replaceLeaderboardsInProject = async ({ projectName: project.getName(), projectAuthor: project.getAuthor(), // Assume the project is not saved at this stage. - isProjectSaved: true, + isProjectSaved: false, }) ); } catch (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 ecea96b59b5e..000000000000 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js +++ /dev/null @@ -1,318 +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 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 { - limits, - cloudProjectsFetchingErrorLabel, - onCloudProjectsChanged, - } = authenticatedUser; - const { windowSize, isMobile, isLandscape } = useResponsiveWindowSize(); - - const columnsCount = getItemsColumns(windowSize, isLandscape); - - const hasTooManyCloudProjects = checkIfHasTooManyCloudProjects( - authenticatedUser - ); - - const examplesAndTemplatesToDisplay = React.useMemo( - () => - getExampleAndTemplateTiles({ - receivedGameTemplates: authenticatedUser.receivedGameTemplates, - privateGameTemplateListingDatas, - exampleShortHeaders, - onSelectPrivateGameTemplateListingData, - onSelectExampleShortHeader, - i18n, - numberOfItemsExclusivelyInCarousel: isMobile ? 3 : 5, - numberOfItemsInCarousel: isMobile ? 8 : 12, - privateGameTemplatesPeriodicity: 2, - }), - [ - authenticatedUser.receivedGameTemplates, - exampleShortHeaders, - onSelectExampleShortHeader, - onSelectPrivateGameTemplateListingData, - privateGameTemplateListingDatas, - i18n, - isMobile, - ] - ); - - 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 - /> - - - - - - - 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; -}; - -const BuildSectionWithErrorBoundary = (props: Props) => ( - Build section} - scope="start-page-create" - > - - -); - -export default BuildSectionWithErrorBoundary; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index 4a9bb2b2df83..f8ce83a8d9be 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -46,6 +46,7 @@ import { MaxProjectCountAlertMessage, } from './MaxProjectCountAlertMessage'; import { SubscriptionSuggestionContext } from '../../../../Profile/Subscription/SubscriptionSuggestionContext'; +import { useProjectsListFor } from './utils'; const getExampleItemsColumns = ( windowSize: WindowSizeType, @@ -164,6 +165,9 @@ const CreateSection = ({ const hasTooManyCloudProjects = checkIfHasTooManyCloudProjects( authenticatedUser ); + const allRecentProjectFiles = useProjectsListFor(null); + const hasAProjectOpenedOrSavedOrGameRegistered = + !!project || (!!games && games.length) || !!allRecentProjectFiles; const hidePerformanceDashboard = !!limits && !!limits.capabilities.classrooms && @@ -395,10 +399,10 @@ const CreateSection = ({ leftIcon={} /> - {games && games.length !== 0 ? ( + {hasAProjectOpenedOrSavedOrGameRegistered ? ( - + - {/* Check if looks ok */} {isMobile && limits && hasTooManyCloudProjects && ( - {!games || games.length === 0 ? ( + {hasAProjectOpenedOrSavedOrGameRegistered ? ( Publish your first game ) : ( Publish a game in 1 minute @@ -478,7 +481,7 @@ const CreateSection = ({ /> )} - {(!games || games.length === 0) && !project && ( + {!hasAProjectOpenedOrSavedOrGameRegistered && ( diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js index f6054544ae12..c9bbd5f4b926 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/utils.js @@ -111,9 +111,6 @@ export const useProjectsListFor = (game: ?Game) => { file => !game || (file.fileMetadata && file.fileMetadata.gameId === game.id) ); - console.log(projectFiles); - console.log(getRecentProjectFiles()); - if (cloudProjects) { projectFiles = projectFiles.concat( transformCloudProjectsIntoFileMetadataWithStorageProviderName( diff --git a/newIDE/app/src/Utils/GDevelopServices/Game.js b/newIDE/app/src/Utils/GDevelopServices/Game.js index 4f6651d168d4..793d60115123 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, @@ -69,7 +71,7 @@ export type Game = {| playWithKeyboard: boolean, playWithMobile: boolean, playWithGamepad: boolean, - unsaved?: boolean, + savedStatus?: SavedStatus, |}; export type GameUpdatePayload = {| @@ -88,7 +90,7 @@ export type GameUpdatePayload = {| acceptsBuildComments?: boolean, acceptsGameComments?: boolean, displayAdsOnGamePage?: boolean, - unsaved?: boolean, + savedStatus?: SavedStatus, |}; export type GameCategory = { @@ -293,13 +295,13 @@ export const registerGame = async ( gameName, authorName, templateSlug, - unsaved, + savedStatus, }: {| gameId: string, gameName: string, authorName: string, templateSlug?: string, - unsaved?: boolean, + savedStatus?: SavedStatus, |} ): Promise => { const authorizationHeader = await getAuthorizationHeader(); @@ -310,7 +312,7 @@ export const registerGame = async ( gameName, authorName, templateSlug, - unsaved, + savedStatus, }, { params: { @@ -345,7 +347,7 @@ export const updateGame = async ( acceptsBuildComments, acceptsGameComments, displayAdsOnGamePage, - unsaved, + savedStatus, }: GameUpdatePayload ): Promise => { const authorizationHeader = await getAuthorizationHeader(); @@ -367,7 +369,7 @@ export const updateGame = async ( acceptsBuildComments, acceptsGameComments, displayAdsOnGamePage, - unsaved, + savedStatus, }, { params: { diff --git a/newIDE/app/src/Utils/UseCreateProject.js b/newIDE/app/src/Utils/UseCreateProject.js index 8893a4f7653c..8f4eff82c95c 100644 --- a/newIDE/app/src/Utils/UseCreateProject.js +++ b/newIDE/app/src/Utils/UseCreateProject.js @@ -170,8 +170,11 @@ const useCreateProject = ({ projectId: currentProject.getProjectUuid(), projectName: currentProject.getName(), projectAuthor: currentProject.getAuthor(), - // Project is not saved yet here. - isProjectSaved: false, + // Project is saved if choosing cloud or local storage provider. + isProjectSaved: + newProjectSetup.storageProvider.internalName === + 'LocalFile' || + newProjectSetup.storageProvider.internalName === 'Cloud', }) ); await onGameRegistered(); diff --git a/newIDE/app/src/Utils/UseGameAndBuildsManager.js b/newIDE/app/src/Utils/UseGameAndBuildsManager.js index 169d453a3c38..250e17356d4c 100644 --- a/newIDE/app/src/Utils/UseGameAndBuildsManager.js +++ b/newIDE/app/src/Utils/UseGameAndBuildsManager.js @@ -33,7 +33,7 @@ export const getDefaultRegisterGameProperties = ({ gameId: projectId, authorName: projectAuthor || 'Unspecified publisher', gameName: projectName || 'Untitled game', - unsaved: !isProjectSaved, + savedStatus: isProjectSaved ? 'saved' : 'draft', }); export type GameManager = {| From d486b7f168e69b90e1a45a1a0b073ae8b1aa2ec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:59:03 +0100 Subject: [PATCH 09/26] Save page & improve buttons --- newIDE/app/src/GameDashboard/GameCard.js | 24 ++++++++++------- newIDE/app/src/GameDashboard/GamesList.js | 22 +++++++++------- newIDE/app/src/GameDashboard/ProjectCard.js | 4 +-- .../HomePage/CreateSection/index.js | 26 ++++++++++++++++++- .../app/src/MarketingPlans/MarketingPlans.js | 15 +++++++---- .../UsePurchaseMarketingPlan.js | 10 ++++++- .../app/src/UI/RaisedButtonWithSplitMenu.js | 14 ++++++++-- .../GameDashboard/GamesList.stories.js | 8 ++++++ 8 files changed, 93 insertions(+), 30 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GameCard.js b/newIDE/app/src/GameDashboard/GameCard.js index ece70677bb1c..f154308637dc 100644 --- a/newIDE/app/src/GameDashboard/GameCard.js +++ b/newIDE/app/src/GameDashboard/GameCard.js @@ -37,6 +37,7 @@ import { import FlatButtonWithSplitMenu from '../UI/FlatButtonWithSplitMenu'; import useOnResize from '../Utils/UseOnResize'; import useForceUpdate from '../Utils/UseForceUpdate'; +import RaisedButtonWithSplitMenu from '../UI/RaisedButtonWithSplitMenu'; const styles = { buttonsContainer: { display: 'flex', flexShrink: 0 }, @@ -259,7 +260,7 @@ const GameCard = ({ )} {projectsList.length === 0 ? ( isCurrentProjectOpened ? ( - ) : null - ) : ( + ) : isCurrentProjectOpened ? ( Opened} + onClick={undefined} + buildMenuTemplate={i18n => buildContextMenu(i18n, projectsList)} + disabled={disabled} + /> + ) : ( + Opened - ) : isWidthConstrained ? ( + isWidthConstrained ? ( Open ) : ( Open project ) } - onClick={ - isCurrentProjectOpened - ? undefined - : () => onOpenProject(projectsList[0]) - } + onClick={() => onOpenProject(projectsList[0])} buildMenuTemplate={i18n => buildContextMenu(i18n, projectsList)} disabled={disabled} /> diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index a10da97d9d68..0ebcff3d86cf 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -43,7 +43,7 @@ import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; import Refresh from '../UI/CustomSvgIcons/Refresh'; import ProjectCard from './ProjectCard'; -const pageSize = 10; +export const pageSize = 10; const styles = { noGameMessageContainer: { padding: 10 }, @@ -213,8 +213,8 @@ const getDashboardItemsToDisplay = ({ ); return itemsWithoutUnsavedGames.slice( - currentPage * pageSize, - (currentPage + 1) * pageSize + (currentPage - 1) * pageSize, + currentPage * pageSize ); }; @@ -235,6 +235,8 @@ type Props = {| askToCloseProject: () => Promise, onSaveProject: () => Promise, canSaveProject: boolean, + currentPage: number, + onCurrentPageChange: (currentPage: number) => void, |}; const GamesList = ({ @@ -254,6 +256,9 @@ const GamesList = ({ askToCloseProject, onSaveProject, canSaveProject, + // Make the page controlled, so that it can be saved when navigating to a game. + currentPage, + onCurrentPageChange, }: Props) => { const { cloudProjects, profile, onCloudProjectsChanged } = React.useContext( AuthenticatedUserContext @@ -262,7 +267,6 @@ const GamesList = ({ 'lastModifiedAt' ); const [searchText, setSearchText] = React.useState(''); - const [currentPage, setCurrentPage] = React.useState(0); const { isMobile } = useResponsiveWindowSize(); const allRecentProjectFiles = useProjectsListFor(null); @@ -468,8 +472,8 @@ const GamesList = ({ setCurrentPage(currentPage => currentPage - 1)} - disabled={!!searchText || currentPage === 0} + onClick={() => onCurrentPageChange(currentPage - 1)} + disabled={!!searchText || currentPage === 1} size="small" > @@ -481,13 +485,13 @@ const GamesList = ({ fontVariantNumeric: 'tabular-nums', }} > - {searchText ? 1 : currentPage + 1} + {searchText ? 1 : currentPage} setCurrentPage(currentPage => currentPage + 1)} + onClick={() => onCurrentPageChange(currentPage + 1)} disabled={ - !!searchText || (currentPage + 1) * pageSize >= games.length + !!searchText || currentPage * pageSize >= games.length } size="small" > diff --git a/newIDE/app/src/GameDashboard/ProjectCard.js b/newIDE/app/src/GameDashboard/ProjectCard.js index b46396524521..69eaabd06994 100644 --- a/newIDE/app/src/GameDashboard/ProjectCard.js +++ b/newIDE/app/src/GameDashboard/ProjectCard.js @@ -15,7 +15,6 @@ import { getStorageProviderByInternalName, type LastModifiedInfo, } from '../MainFrame/EditorContainers/HomePage/CreateSection/utils'; -import FlatButtonWithSplitMenu from '../UI/FlatButtonWithSplitMenu'; import useOnResize from '../Utils/UseOnResize'; import useForceUpdate from '../Utils/UseForceUpdate'; import LastModificationInfo from '../MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo'; @@ -30,6 +29,7 @@ import { extractGDevelopApiErrorStatusAndCode } from '../Utils/GDevelopServices/ import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; import { registerGame } from '../Utils/GDevelopServices/Game'; import { getDefaultRegisterGameProperties } from '../Utils/UseGameAndBuildsManager'; +import RaisedButtonWithSplitMenu from '../UI/RaisedButtonWithSplitMenu'; const electron = optionalRequire('electron'); const path = optionalRequire('path'); @@ -312,7 +312,7 @@ const ProjectCard = ({
{fullWidth ? : null} - { + const minPage = 1; + const maxPage = games ? Math.ceil(games.length / pageSize) : 1; + if (newPage < minPage) { + setCurrentPage(minPage); + } else if (newPage > maxPage) { + setCurrentPage(maxPage); + } else { + setCurrentPage(newPage); + } + }, + [setCurrentPage, games] + ); + const unregisterGame = React.useCallback( async (game: Game, i18n: I18nType) => { if (!profile) return; @@ -347,6 +364,11 @@ const CreateSection = ({ + + + + + {isMobile && limits && hasTooManyCloudProjects && ( { @@ -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( @@ -143,9 +147,10 @@ const MarketingPlans = ({ game }: Props) => { activeGameFeaturings ); - const requirementsErrors = isPlanActive - ? getRequirementsErrors(game, marketingPlan) - : []; + const requirementsErrors = + isPlanActive && game + ? getRequirementsErrors(game, marketingPlan) + : []; return ( Promise, |}; @@ -41,6 +41,14 @@ const usePurchaseMarketingPlan = ({ async (i18n: I18nType, marketingPlan: MarketingPlan) => { if (!profile || !limits) return; + if (!game) { + await showAlert({ + title: t`Select a game`, + message: t`In order to purchase a marketing boost, select a game in your dashboard.`, + }); + return; + } + const { id, nameByLocale, diff --git a/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js b/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js index c421482011ad..f7d3fad06857 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 = () => { @@ -48,7 +49,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 +73,7 @@ const RaisedButtonWithSplitMenu = (props: Props) => { disabled={disabled} size="small" style={props.style} + fullWidth={fullWidth} > ); } 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 f9bddcaa8045..000000000000 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GameCard.stories.js +++ /dev/null @@ -1,105 +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, - game2, -} from '../../../fixtures/GDevelopServicesTestData'; -import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; -import CloudStorageProvider from '../../../ProjectsStorage/CloudStorageProvider'; - -export default { - title: 'GameDashboard/GameCard', - component: GameCard, - decorators: [paperDecorator], -}; - -export const UnpublishedGame = () => ( - - - -); - -export const PublishedGame = () => ( - - - -); - -export const CurrentlyOpened = () => ( - - - -); - -export const Saving = () => ( - - - -); - -export const Disabled = () => ( - - - -); diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GameDashboard.stories.js index c8c4f59b8597..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,16 +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 85d0b582618c..0d1e08525068 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js @@ -61,6 +61,8 @@ export const NoGamesOrProjects = () => { onUnregisterGame={action('onUnregisterGame')} currentPage={1} onCurrentPageChange={action('onCurrentPageChange')} + onDeleteCloudProject={action('onDeleteCloudProject')} + onRegisterProject={action('onRegisterProject')} /> @@ -109,6 +111,8 @@ export const WithOnlyGames = () => { onUnregisterGame={action('onUnregisterGame')} currentPage={1} onCurrentPageChange={action('onCurrentPageChange')} + onDeleteCloudProject={action('onDeleteCloudProject')} + onRegisterProject={action('onRegisterProject')} /> @@ -152,6 +156,8 @@ export const WithOnlyProjects = () => { onUnregisterGame={action('onUnregisterGame')} currentPage={1} onCurrentPageChange={action('onCurrentPageChange')} + onDeleteCloudProject={action('onDeleteCloudProject')} + onRegisterProject={action('onRegisterProject')} /> @@ -202,6 +208,8 @@ export const WithGamesAndProjects = () => { onUnregisterGame={action('onUnregisterGame')} currentPage={1} onCurrentPageChange={action('onCurrentPageChange')} + onDeleteCloudProject={action('onDeleteCloudProject')} + onRegisterProject={action('onRegisterProject')} /> diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/ProjectCard.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/ProjectCard.stories.js deleted file mode 100644 index cbf34052669a..000000000000 --- a/newIDE/app/src/stories/componentStories/GameDashboard/ProjectCard.stories.js +++ /dev/null @@ -1,143 +0,0 @@ -// @flow - -import * as React from 'react'; -import { action } from '@storybook/addon-actions'; - -import paperDecorator from '../../PaperDecorator'; -import ProjectCard from '../../../GameDashboard/ProjectCard'; -import { - fakeFileMetadataAndStorageProviderNameForCloudProject, - fakeFileMetadataAndStorageProviderNameForLocalProject, - fakeSilverAuthenticatedUser, -} from '../../../fixtures/GDevelopServicesTestData'; -import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; -import CloudStorageProvider from '../../../ProjectsStorage/CloudStorageProvider'; -import LocalFileStorageProvider from '../../../ProjectsStorage/LocalFileStorageProvider'; - -export default { - title: 'GameDashboard/ProjectCard', - component: ProjectCard, - decorators: [paperDecorator], -}; - -export const LocalProject = () => ( - - - -); - -export const OpenedLocalProject = () => ( - - - -); - -export const DisabledLocalProject = () => ( - - - -); - -export const CloudProject = () => ( - - - -); - -export const OpenedCloudProject = () => ( - - - -); - -export const DisabledCloudProject = () => ( - - - -); From 6fbb7646a389ab1f5143bd3c05dd8868337b0781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:23:37 +0100 Subject: [PATCH 12/26] Some fixes --- .../src/GameDashboard/GameDashboardCard.js | 36 ++++++++++++------- .../CloudProjectWriter.js | 4 ++- newIDE/app/src/UI/TextButton.js | 1 + .../app/src/Utils/GDevelopServices/Project.js | 2 +- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js index cb00313717c4..50578a3553fb 100644 --- a/newIDE/app/src/GameDashboard/GameDashboardCard.js +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -49,6 +49,7 @@ 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'; const electron = optionalRequire('electron'); const path = optionalRequire('path'); @@ -64,10 +65,10 @@ const styles = { ...textEllipsisStyle, overflowWrap: 'break-word', }, + projectFilesButton: { minWidth: 32 }, fileIcon: { width: 16, height: 16, - marginRight: 4, }, }; @@ -263,13 +264,20 @@ const GameDashboardCard = ({ {gameName} - {projectsList.length > 0 && ( + {projectsList.length > 0 && game && ( <> - - - {projectsList.length} - + onOpenGameManager(game)} + icon={} + label={ + + {projectsList.length} + + } + disabled={disabled} + style={styles.projectFilesButton} + /> )} @@ -309,6 +317,8 @@ const GameDashboardCard = ({ ); const name = itemStorageProvider ? ( i18n._(itemStorageProvider.name) + ) : isCurrentProjectOpened ? ( + Project not saved ) : ( Project not found ); @@ -411,16 +421,16 @@ const GameDashboardCard = ({ }); } - if (actions.length > 0) { - actions.push({ - type: 'separator', - }); - } - // 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 < 2) { + if (actions.length > 0) { + actions.push({ + type: 'separator', + }); + } + const file = projectsList[0]; actions.push({ label: i18n._(t`Delete`), @@ -517,7 +527,7 @@ const GameDashboardCard = ({ : () => { showAlert({ title: t`No project found`, - message: t`We couldn't find a project for this game. Try to open the project file manually to get it automatically linked.`, + message: t`We couldn't find a project for this game. You may have saved it in a different location? You can open it manually to get it linked to this game.`, }); }; 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/UI/TextButton.js b/newIDE/app/src/UI/TextButton.js index 1212c3d3a3d1..831cddb59f2f 100644 --- a/newIDE/app/src/UI/TextButton.js +++ b/newIDE/app/src/UI/TextButton.js @@ -22,6 +22,7 @@ type Props = {| marginRight?: number, margin?: number, flexShrink?: 0, + minWidth?: number, |}, target?: '_blank', id?: ?string, diff --git a/newIDE/app/src/Utils/GDevelopServices/Project.js b/newIDE/app/src/Utils/GDevelopServices/Project.js index 7818cecf50e6..5bbe8b8f4e6d 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; From 9e1caf037e2891a8035257065eaa1c615a1913d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 11 Dec 2024 09:58:55 +0100 Subject: [PATCH 13/26] Small design fixes --- .../src/GameDashboard/GameDashboardCard.js | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js index 50578a3553fb..8c6a79208f19 100644 --- a/newIDE/app/src/GameDashboard/GameDashboardCard.js +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -41,7 +41,7 @@ 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 { Line, Spacer } from '../UI/Grid'; +import { Column, Line, Spacer } from '../UI/Grid'; import ElementWithMenu from '../UI/Menu/ElementWithMenu'; import IconButton from '../UI/IconButton'; import ThreeDotsMenu from '../UI/CustomSvgIcons/ThreeDotsMenu'; @@ -193,7 +193,9 @@ const GameDashboardCard = ({ 'Unknown game' : 'Unknown game'; - const { isMobile } = useResponsiveWindowSize(); + const { isMobile, windowSize } = useResponsiveWindowSize(); + const isSmallOrMediumScreen = + windowSize === 'small' || windowSize === 'medium'; const gdevelopTheme = React.useContext(GDevelopThemeContext); const itemStorageProvider = projectFileMetadataAndStorageProviderName ? getStorageProviderByInternalName( @@ -296,10 +298,10 @@ const GameDashboardCard = ({ /> ) : game ? ( - + Last edited: - + {i18n.date(game.updatedAt * 1000)} @@ -567,7 +569,12 @@ const GameDashboardCard = ({ }; const renderShareUrl = (i18n: I18nType) => - gameUrl ? : null; + gameUrl ? ( + + ) : null; return ( @@ -579,15 +586,14 @@ const GameDashboardCard = ({ > {isMobile ? ( - - {renderTitle()} - {renderLastModification(i18n)} + + + {renderTitle()} + {renderLastModification(i18n)} + + {renderAdditionalActions()} - + {renderThumbnail()} {renderPublicInfo()} @@ -613,7 +619,7 @@ const GameDashboardCard = ({ {renderTitle()} - {renderStorageProvider(i18n)} + {!isSmallOrMediumScreen && renderStorageProvider(i18n)} {renderAdditionalActions()} From 021f143067054e1a6e122b0f470e9ef9ed3bb480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 11 Dec 2024 16:36:44 +0100 Subject: [PATCH 14/26] Improvements & fixes --- .../src/GameDashboard/GameDashboardCard.js | 26 ++-- newIDE/app/src/GameDashboard/GamesList.js | 11 +- .../Monetization/UserEarningsWidget.js | 6 +- .../src/GameDashboard/Wallet/WalletWidget.js | 1 + .../Widgets/AllFeedbacksWidget.js | 49 ------- .../GameDashboard/Widgets/AnalyticsWidget.js | 1 + .../src/GameDashboard/Widgets/BuildsWidget.js | 1 + .../GameDashboard/Widgets/DashboardWidget.js | 13 +- .../GameDashboard/Widgets/FeedbackWidget.js | 1 + .../GameDashboard/Widgets/ProjectsWidget.js | 6 +- .../GameDashboard/Widgets/ServicesWidget.js | 6 +- .../GameDashboard/Widgets/TotalPlaysWidget.js | 126 ------------------ newIDE/app/src/GameDashboard/index.js | 23 +++- .../HomePage/CreateSection/index.js | 17 ++- .../app/src/UI/RaisedButtonWithSplitMenu.js | 2 + .../GameDashboard/GamesList.stories.js | 8 +- 16 files changed, 93 insertions(+), 204 deletions(-) delete mode 100644 newIDE/app/src/GameDashboard/Widgets/AllFeedbacksWidget.js delete mode 100644 newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js diff --git a/newIDE/app/src/GameDashboard/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js index 8c6a79208f19..839b91f03f28 100644 --- a/newIDE/app/src/GameDashboard/GameDashboardCard.js +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -120,7 +120,7 @@ type Props = {| dashboardItem: DashboardItem, storageProviders: Array, isCurrentProjectOpened: boolean, - onOpenGameManager: (game: Game) => void, + onOpenGameManager: ({ game: Game, widgetToScrollTo?: 'projects' }) => void, onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, onUnregisterGame: () => Promise, disabled: boolean, @@ -270,7 +270,9 @@ const GameDashboardCard = ({ <> onOpenGameManager(game)} + onClick={() => + onOpenGameManager({ game, widgetToScrollTo: 'projects' }) + } icon={} label={ @@ -376,7 +378,7 @@ const GameDashboardCard = ({ { type: 'separator' }, { label: i18n._(t`See all in the game dashboard`), - click: game ? () => onOpenGameManager(game) : undefined, + click: game ? () => onOpenGameManager({ game }) : undefined, }, ] ); @@ -419,7 +421,7 @@ const GameDashboardCard = ({ // If there are multiple projects, suggest opening the game dashboard. actions.push({ label: i18n._(t`See all projects`), - click: game ? () => onOpenGameManager(game) : undefined, + click: game ? () => onOpenGameManager({ game }) : undefined, }); } @@ -484,7 +486,7 @@ const GameDashboardCard = ({ const onManageGame = React.useCallback( async () => { if (game) { - onOpenGameManager(game); + onOpenGameManager({ game }); return; } else { if (!authenticatedUser.profile) { @@ -502,7 +504,7 @@ const GameDashboardCard = ({ if (!registeredGame) return; await onRefreshGames(); - onOpenGameManager(registeredGame); + onOpenGameManager({ game: registeredGame }); } }, [ @@ -586,13 +588,13 @@ const GameDashboardCard = ({ > {isMobile ? ( - - + + {renderTitle()} - {renderLastModification(i18n)} - - {renderAdditionalActions()} - + {renderAdditionalActions()} + + {renderLastModification(i18n)} + {renderThumbnail()} {renderPublicInfo()} diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 52c984041f4c..5fcf3146dbb1 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -222,7 +222,10 @@ type Props = {| currentFileMetadata: ?FileMetadata, games: Array, onRefreshGames: () => Promise, - onOpenGameId: (gameId: ?string) => void, + onOpenGameManager: ({ + game: Game, + widgetToScrollTo?: 'projects', + }) => void, onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise, onUnregisterGame: ( gameId: string, @@ -254,7 +257,7 @@ const GamesList = ({ currentFileMetadata, games, onRefreshGames, - onOpenGameId, + onOpenGameManager, onOpenProject, onUnregisterGame, onRegisterProject, @@ -544,9 +547,7 @@ const GamesList = ({ dashboardItem={dashboardItem} storageProviders={storageProviders} isCurrentProjectOpened={isCurrentProjectOpened} - onOpenGameManager={(gameToOpen: Game) => { - onOpenGameId(gameToOpen.id); - }} + onOpenGameManager={onOpenGameManager} onOpenProject={onOpenProject} onUnregisterGame={async () => { if (!game) return; diff --git a/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js b/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js index ba3a080f4a1d..602a6bd86350 100644 --- a/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js +++ b/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js @@ -220,7 +220,11 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { return ( <> - + {content} {selectedCashOutType && userEarningsBalance && ( diff --git a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js index 3f6f9ea3a770..17d95a0a5b2c 100644 --- a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js +++ b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js @@ -40,6 +40,7 @@ const WalletWidget = ({ onClick={profile ? onOpenProfile : onOpenCreateAccountDialog} /> } + widgetName="wallet" > , -|}; - -const AllFeedbacksWidget = ({ feedbacks }: Props) => { - const unprocessedFeedbacks = feedbacks.filter( - comment => !comment.processedAt - ); - - return ( - - {({ i18n }) => ( - Feedbacks} - topRightAction={ - - {!!unprocessedFeedbacks.length && ( - - )} - - {unprocessedFeedbacks.length === 0 ? ( - No new feedback - ) : unprocessedFeedbacks.length === 1 ? ( - 1 new feedback - ) : ( - {unprocessedFeedbacks.length} new feedbacks - )} - - - } - /> - )} - - ); -}; - -export default AllFeedbacksWidget; diff --git a/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js b/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js index e609e5f21853..0a7bdee9e4a2 100644 --- a/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js @@ -63,6 +63,7 @@ const AnalyticsWidget = ({ game, onSeeAll, gameMetrics, gameUrl }: Props) => { primary /> } + widgetName="analytics" > {!gameMetrics ? ( diff --git a/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js b/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js index bcbdd45ae84b..e582b3274f74 100644 --- a/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js @@ -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 89df8d717977..4b027f046572 100644 --- a/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js @@ -23,6 +23,15 @@ const styles = { }, }; +type GameDashboardWidgetName = + | 'analytics' + | 'feedback' + | 'services' + | 'projects' + | 'builds' + | 'wallet' + | 'earnings'; + type Props = {| title: React.Node, topRightAction?: React.Node, @@ -30,6 +39,7 @@ type Props = {| gridSize: number, children?: React.Node, minHeight?: boolean, + widgetName: GameDashboardWidgetName, |}; const DashboardWidget = ({ @@ -39,10 +49,11 @@ const DashboardWidget = ({ renderSubtitle, children, minHeight, + widgetName, }: Props) => { const { isMobile } = useResponsiveWindowSize(); return ( - + ) } + widgetName="feedback" minHeight renderSubtitle={() => shouldDisplayControlToCollectFeedback ? null : unprocessedFeedbacks && diff --git a/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js b/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js index f98beb95f04d..81b99dd8fa41 100644 --- a/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js @@ -28,7 +28,11 @@ type Props = {| const ProjectsWidget = (props: Props) => { return ( - Projects}> + Projects} + widgetName="projects" + > ); diff --git a/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js b/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js index db2276b582ed..02b791e63d0f 100644 --- a/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js @@ -38,7 +38,11 @@ const ServicesWidget = ({ SubscriptionSuggestionContext ); return ( - Player services}> + Player services} + widgetName="services" + > diff --git a/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js b/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js deleted file mode 100644 index 787ccc18bc0a..000000000000 --- a/newIDE/app/src/GameDashboard/Widgets/TotalPlaysWidget.js +++ /dev/null @@ -1,126 +0,0 @@ -// @flow -import { Trans } from '@lingui/macro'; -import * as React from 'react'; - -import { LineStackLayout } from '../../UI/Layout'; -import Text from '../../UI/Text'; -import { Column, Spacer } from '../../UI/Grid'; -import BackgroundText from '../../UI/BackgroundText'; -import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; -import DashboardWidget from './DashboardWidget'; -import { type Game } from '../../Utils/GDevelopServices/Game'; - -const styles = { - separator: { - height: 50, - }, -}; - -// Helper to display 5.5k instead of 5502, or 1.2m instead of 1234567 -export const formatPlays = (plays: number) => { - if (plays < 1000) return plays.toString(); - if (plays < 1000000) return `${(plays / 1000).toFixed(1)}k`; - return `${(plays / 1000000).toFixed(1)}m`; -}; - -type Props = {| - games: Array, - fullWidth?: boolean, -|}; - -const TotalPlaysWidget = ({ games, fullWidth }: Props) => { - const theme = React.useContext(GDevelopThemeContext); - - const { - allGamesLastWeekPlays, - allGamesLastYearPlays, - allGamesTotalPlays, - } = React.useMemo( - () => { - const allGamesLastWeekPlays = games.reduce( - (acc, game) => acc + (game.cachedLastWeekSessionsCount || 0), - 0 - ); - const allGamesLastYearPlays = games.reduce( - (acc, game) => acc + (game.cachedLastYearSessionsCount || 0), - 0 - ); - const allGamesTotalPlays = games.reduce( - (acc, game) => acc + (game.cachedTotalSessionsCount || 0), - 0 - ); - - return { - allGamesLastWeekPlays, - allGamesLastYearPlays, - allGamesTotalPlays, - }; - }, - [games] - ); - - return ( - Total plays} - > - - - - - {formatPlays(allGamesLastWeekPlays)} - - - Last week - - - -
- - - - {formatPlays(allGamesLastYearPlays)} - - - Last year - - - -
- - - - {formatPlays(allGamesTotalPlays)} - - - Overall - - - - - - - ); -}; - -export default TotalPlaysWidget; diff --git a/newIDE/app/src/GameDashboard/index.js b/newIDE/app/src/GameDashboard/index.js index d75fcd010dfc..58814648d46e 100644 --- a/newIDE/app/src/GameDashboard/index.js +++ b/newIDE/app/src/GameDashboard/index.js @@ -88,6 +88,7 @@ type Props = {| setCurrentView: GameDetailsTab => void, onBack: () => void, disabled: boolean, + initialWidgetToScrollTo?: ?string, |}; const GameDashboard = ({ @@ -109,7 +110,12 @@ const GameDashboard = ({ setCurrentView, onBack, disabled, + initialWidgetToScrollTo, }: Props) => { + const grid = React.useRef(null); + const [widgetToScrollTo, setWidgetToScrollTo] = React.useState( + initialWidgetToScrollTo + ); const [ gameDetailsDialogOpen, setGameDetailsDialogOpen, @@ -414,6 +420,21 @@ const GameDashboard = ({ [fetchPublicGame] ); + 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] + ); + React.useEffect( () => { if (!profile) { @@ -538,7 +559,7 @@ const GameDashboard = ({ : null } /> - + setCurrentView('analytics')} gameMetrics={gameRollingMetrics} diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index 3b08e0992176..d3157d55e1f2 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -143,6 +143,9 @@ const CreateSection = ({ 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 @@ -386,8 +389,6 @@ const CreateSection = ({ or retry later.`, }); return; - - // TODO: should we generate a gameId in this case? } const { id, username } = authenticatedUser.profile; @@ -451,6 +452,7 @@ const CreateSection = ({ disabled={isUpdatingGame} onUnregisterGame={() => onUnregisterGame(openedGame.id, i18n)} onDeleteCloudProject={onDeleteCloudProject} + initialWidgetToScrollTo={initialWidgetToScrollTo} /> ); @@ -527,7 +529,16 @@ const CreateSection = ({ project={project} games={games || []} onRefreshGames={onRefreshGames} - onOpenGameId={setOpenedGameId} + onOpenGameManager={({ + game, + widgetToScrollTo, + }: { + game: Game, + widgetToScrollTo?: 'projects', + }) => { + setInitialWidgetToScrollTo(widgetToScrollTo); + setOpenedGameId(game.id); + }} onOpenProject={onOpenProject} isUpdatingGame={isUpdatingGame} onUnregisterGame={onUnregisterGame} diff --git a/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js b/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js index f7d3fad06857..09d6fe3a8b69 100644 --- a/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js +++ b/newIDE/app/src/UI/RaisedButtonWithSplitMenu.js @@ -41,6 +41,8 @@ const styles = { minWidth: 30, paddingLeft: 0, paddingRight: 0, + // Make the button shrink to its minimum size. + flexBasis: 0, }, }; diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js index 0d1e08525068..e4ba7bf13d60 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js @@ -47,7 +47,7 @@ export const NoGamesOrProjects = () => { project={null} games={[]} onRefreshGames={action('onRefreshGames')} - onOpenGameId={action('onOpenGameId')} + onOpenGameManager={action('onOpenGameManager')} onOpenProject={action('onOpenProject')} canOpen={true} askToCloseProject={action('askToCloseProject')} @@ -97,7 +97,7 @@ export const WithOnlyGames = () => { project={null} games={[game1, game2]} onRefreshGames={action('onRefreshGames')} - onOpenGameId={action('onOpenGameId')} + onOpenGameManager={action('onOpenGameManager')} onOpenProject={action('onOpenProject')} canOpen={true} askToCloseProject={action('askToCloseProject')} @@ -142,7 +142,7 @@ export const WithOnlyProjects = () => { project={null} games={[]} onRefreshGames={action('onRefreshGames')} - onOpenGameId={action('onOpenGameId')} + onOpenGameManager={action('onOpenGameManager')} onOpenProject={action('onOpenProject')} canOpen={true} askToCloseProject={action('askToCloseProject')} @@ -194,7 +194,7 @@ export const WithGamesAndProjects = () => { project={null} games={[game1, game2]} onRefreshGames={action('onRefreshGames')} - onOpenGameId={action('onOpenGameId')} + onOpenGameManager={action('onOpenGameManager')} onOpenProject={action('onOpenProject')} canOpen={true} askToCloseProject={action('askToCloseProject')} From f5138807457db1e699145fb4237189ebf5f8bf30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 11 Dec 2024 17:44:39 +0100 Subject: [PATCH 15/26] Fix search --- newIDE/app/src/GameDashboard/GamesList.js | 27 ++++++++++--------- .../CreateSection/LastModificationInfo.js | 4 +-- .../HomePage/GetStartedSection/EarnBadges.js | 6 ++++- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 5fcf3146dbb1..9011fb4566ba 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -14,7 +14,7 @@ import { import SearchBar from '../UI/SearchBar'; import { useDebounce } from '../Utils/UseDebounce'; import { - getFuseSearchQueryForSimpleArray, + getFuseSearchQueryForMultipleKeys, sharedFuseConfiguration, } from '../UI/Search/UseSearchStructuredItem'; import IconButton from '../UI/IconButton'; @@ -135,22 +135,25 @@ const getDashboardItemsToDisplay = ({ |}): Array => { let itemsToDisplay: DashboardItem[] = allDashboardItems; - // Always order items, with or without search. - itemsToDisplay = - orderBy === 'totalSessions' - ? itemsToDisplay.sort(totalSessionsSort) - : orderBy === 'weeklySessions' - ? itemsToDisplay.sort(lastWeekSessionsSort) - : orderBy === 'lastModifiedAt' - ? itemsToDisplay.sort(lastModifiedAtSort) - : itemsToDisplay; - if (searchText) { const searchResults = searchClient.search( - getFuseSearchQueryForSimpleArray(searchText) + getFuseSearchQueryForMultipleKeys(searchText, [ + 'game.gameName', + 'projectFiles.fileMetadata.name', + ]) ); itemsToDisplay = searchResults.map(result => result.item); } else { + // Order items first. + itemsToDisplay = + orderBy === 'totalSessions' + ? itemsToDisplay.sort(totalSessionsSort) + : orderBy === 'weeklySessions' + ? itemsToDisplay.sort(lastWeekSessionsSort) + : orderBy === 'lastModifiedAt' + ? itemsToDisplay.sort(lastModifiedAtSort) + : itemsToDisplay; + // If a project is opened and no search is performed, display it first. if (project) { const currentProjectId = project.getProjectUuid(); diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo.js index 2f9197f2aef9..4cabbcb80daf 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/LastModificationInfo.js @@ -77,7 +77,7 @@ const LastModificationInfo = ({ {({ i18n }) => ( {textPrefix && ( - + {textPrefix} )} @@ -97,7 +97,7 @@ const LastModificationInfo = ({ hideStatus={!isProjectOpenedNotTheLatestVersion} /> )} - + {isCurrentProjectOpened ? ( Modifying ) : ( diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js index 6e53533a50bc..7539163c7671 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js @@ -224,7 +224,11 @@ export const EarnBadges = ({ forceMobileLayout={isMobileOrMediumWidth} > {badgesSlicedInArraysOfTwo.map((badges, index) => ( - + {badges.map(badge => ( Date: Thu, 12 Dec 2024 12:18:21 +0100 Subject: [PATCH 16/26] Fixes --- .../src/GameDashboard/GameDashboardCard.js | 35 ++-- newIDE/app/src/GameDashboard/GamesList.js | 161 +++++++++++------- .../GameDashboard/LeaderboardAdmin/index.js | 16 +- .../Monetization/UserEarningsWidget.js | 20 ++- .../src/GameDashboard/Wallet/WalletWidget.js | 3 +- .../GameDashboard/Widgets/AnalyticsWidget.js | 5 +- newIDE/app/src/GameDashboard/index.js | 81 ++++----- .../src/Leaderboard/UseLeaderboardReplacer.js | 2 +- .../HomePage/CreateSection/index.js | 29 ++-- .../UseMultiplayerLobbyConfigurator.js | 2 +- .../src/UI/ShareDialog/SocialShareButtons.js | 2 +- newIDE/app/src/Utils/UseCreateProject.js | 6 +- .../app/src/Utils/UseGameAndBuildsManager.js | 9 +- .../GameDashboard/GamesList.stories.js | 48 +++++- ...ories.js => UserEarningsWidget.stories.js} | 0 15 files changed, 250 insertions(+), 169 deletions(-) rename newIDE/app/src/stories/componentStories/GameDashboard/Monetization/{UserEarnings.stories.js => UserEarningsWidget.stories.js} (100%) diff --git a/newIDE/app/src/GameDashboard/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js index 839b91f03f28..7e479a00360b 100644 --- a/newIDE/app/src/GameDashboard/GameDashboardCard.js +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -50,6 +50,7 @@ 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'); @@ -269,19 +270,29 @@ const GameDashboardCard = ({ {projectsList.length > 0 && game && ( <> - - onOpenGameManager({ game, widgetToScrollTo: 'projects' }) - } - icon={} - label={ - - {projectsList.length} - + {projectsList.length} project + ) : ( + {projectsList.length} projects + ) } - disabled={disabled} - style={styles.projectFilesButton} - /> + > + + onOpenGameManager({ game, widgetToScrollTo: 'projects' }) + } + icon={} + label={ + + {projectsList.length} + + } + disabled={disabled} + style={styles.projectFilesButton} + /> + )} diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 9011fb4566ba..1f4095ea59e0 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -46,14 +46,14 @@ const electron = optionalRequire('electron'); const isDesktop = !!electron; -export const pageSize = 10; +const pageSize = 10; const styles = { noGameMessageContainer: { padding: 10 }, refreshIconContainer: { fontSize: 20, display: 'flex', alignItems: 'center' }, }; -type OrderBy = 'totalSessions' | 'weeklySessions' | 'lastModifiedAt'; +export type OrderBy = 'totalSessions' | 'weeklySessions' | 'lastModifiedAt'; const totalSessionsSort = ( itemA: DashboardItem, @@ -203,20 +203,20 @@ const getDashboardItemsToDisplay = ({ itemsToDisplay = [openedProjectDashboardItem, ...itemsToDisplay]; } } + + itemsToDisplay = itemsToDisplay.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize + ); } - const itemsWithoutUnsavedGames = itemsToDisplay.filter( + return itemsToDisplay.filter( item => // Filter out unsaved games, unless they are the opened project. !item.game || item.game.savedStatus !== 'draft' || (project && item.game.id === project.getProjectUuid()) ); - - return itemsWithoutUnsavedGames.slice( - (currentPage - 1) * pageSize, - currentPage * pageSize - ); }; type Props = {| @@ -246,13 +246,18 @@ type Props = {| askToCloseProject: () => Promise, onSaveProject: () => Promise, canSaveProject: boolean, - currentPage: number, - onCurrentPageChange: (currentPage: number) => void, onDeleteCloudProject: ( i18n: I18nType, file: FileMetadataAndStorageProviderName, options?: { skipConfirmation: boolean } ) => Promise, + // Controls + currentPage: number, + setCurrentPage: (currentPage: number) => void, + orderBy: OrderBy, + setGamesListOrderBy: (orderBy: OrderBy) => void, + searchText: string, + setSearchText: (searchText: string) => void, |}; const GamesList = ({ @@ -276,15 +281,15 @@ const GamesList = ({ canSaveProject, // Make the page controlled, so that it can be saved when navigating to a game. currentPage, - onCurrentPageChange, + setCurrentPage, + orderBy, + setGamesListOrderBy, + searchText, + setSearchText, }: Props) => { const { cloudProjects, profile, onCloudProjectsChanged } = React.useContext( AuthenticatedUserContext ); - const [orderBy, setGamesListOrderBy] = React.useState( - 'lastModifiedAt' - ); - const [searchText, setSearchText] = React.useState(''); const { isMobile } = useResponsiveWindowSize(); const allRecentProjectFiles = useProjectsListFor(null); @@ -306,6 +311,22 @@ const GamesList = ({ [games, allRecentProjectFiles] ); + const totalNumberOfPages = Math.ceil(allDashboardItems.length / pageSize); + 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(allDashboardItems, { @@ -467,60 +488,70 @@ const GamesList = ({ {allDashboardItems.length > 0 && ( - - // $FlowFixMe - setGamesListOrderBy(value) - } - > - - - + {}} + placeholder={t`Search by name`} /> - - - - {}} - placeholder={t`Search by name`} - /> - - onCurrentPageChange(currentPage - 1)} - disabled={!!searchText || currentPage === 1} - size="small" + + + + // $FlowFixMe + setGamesListOrderBy(value) + } > - - - + + + + - {searchText ? 1 : currentPage} - - onCurrentPageChange(currentPage + 1)} - disabled={ - !!searchText || currentPage * pageSize >= games.length - } - size="small" + expand + alignItems="center" + justifyContent="flex-end" > - - + onCurrentPageChange(currentPage - 1)} + disabled={!!searchText || currentPage === 1} + size="small" + > + + + + {searchText || totalNumberOfPages === 1 + ? 1 + : `${currentPage}/${totalNumberOfPages}`} + + onCurrentPageChange(currentPage + 1)} + disabled={!!searchText || currentPage >= totalNumberOfPages} + size="small" + > + + + )} 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/UserEarningsWidget.js b/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js index 602a6bd86350..4efe862f6f92 100644 --- a/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js +++ b/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js @@ -34,9 +34,11 @@ type Props = {| |}; const UserEarningsWidget = ({ fullWidth }: Props) => { - const { userEarningsBalance, onRefreshEarningsBalance } = React.useContext( - AuthenticatedUserContext - ); + const { + userEarningsBalance, + onRefreshEarningsBalance, + onRefreshLimits, + } = React.useContext(AuthenticatedUserContext); const theme = React.useContext(GDevelopThemeContext); const { isMobile } = useResponsiveWindowSize(); @@ -111,6 +113,14 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { [] ); + const onCashOrCreditOut = React.useCallback( + async () => { + await onRefreshEarningsBalance(); + await onRefreshLimits(); + }, + [onRefreshEarningsBalance, onRefreshLimits] + ); + const canCashout = userEarningsBalance && earningsInMilliUsd >= userEarningsBalance.minAmountToCashoutInMilliUSDs; @@ -222,7 +232,7 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { <> Game earnings} widgetName="earnings" > {content} @@ -231,7 +241,7 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { setSelectedCashOutType(null)} - onSuccess={onRefreshEarningsBalance} + onSuccess={onCashOrCreditOut} type={selectedCashOutType} /> )} diff --git a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js index 17d95a0a5b2c..e592b6add0aa 100644 --- a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js +++ b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js @@ -7,6 +7,7 @@ import Coin from '../../Credits/Icons/Coin'; import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; import { EarnBadges } from '../../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges'; import TextButton from '../../UI/TextButton'; +import { Trans } from '@lingui/macro'; type Props = {| onOpenProfile: () => void, @@ -32,7 +33,7 @@ const WalletWidget = ({ return ( Wallet} topRightAction={ } diff --git a/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js b/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js index 0a7bdee9e4a2..56e9b8287d9f 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 } }; diff --git a/newIDE/app/src/GameDashboard/index.js b/newIDE/app/src/GameDashboard/index.js index 58814648d46e..38dad0ee54d2 100644 --- a/newIDE/app/src/GameDashboard/index.js +++ b/newIDE/app/src/GameDashboard/index.js @@ -435,8 +435,8 @@ const GameDashboard = ({ [initialWidgetToScrollTo, widgetToScrollTo] ); - React.useEffect( - () => { + const fetchAuthenticatedData = React.useCallback( + async () => { if (!profile) { setFeedbacks(null); setBuilds(null); @@ -447,46 +447,45 @@ 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, + 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); + }, + [fetchGameFeaturings, game.id, getAuthorizationHeader, profile] + ); + React.useEffect( + () => { fetchAuthenticatedData(); }, - [getAuthorizationHeader, profile, fetchGameFeaturings, game.id] + [fetchAuthenticatedData] ); const onClickBack = React.useCallback( @@ -494,10 +493,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 ( diff --git a/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js b/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js index b04ff8ee2f98..50fa5f4a5881 100644 --- a/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js +++ b/newIDE/app/src/Leaderboard/UseLeaderboardReplacer.js @@ -245,7 +245,7 @@ export const replaceLeaderboardsInProject = async ({ projectName: project.getName(), projectAuthor: project.getAuthor(), // Assume the project is not saved at this stage. - isProjectSaved: false, + savedStatus: 'draft', }) ); } catch (error) { diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index d3157d55e1f2..58659876ca2f 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -6,7 +6,7 @@ import { I18n as I18nType } from '@lingui/core'; import SectionContainer, { SectionRow } from '../SectionContainer'; import ErrorBoundary from '../../../../UI/ErrorBoundary'; import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext'; -import GamesList, { pageSize } from '../../../../GameDashboard/GamesList'; +import GamesList, { type OrderBy } from '../../../../GameDashboard/GamesList'; import { deleteGame, registerGame, @@ -198,20 +198,10 @@ const CreateSection = ({ ); const [currentPage, setCurrentPage] = React.useState(1); - const onCurrentPageChange = React.useCallback( - newPage => { - const minPage = 1; - const maxPage = games ? Math.ceil(games.length / pageSize) : 1; - if (newPage < minPage) { - setCurrentPage(minPage); - } else if (newPage > maxPage) { - setCurrentPage(maxPage); - } else { - setCurrentPage(newPage); - } - }, - [setCurrentPage, games] + const [orderBy, setGamesListOrderBy] = React.useState( + 'lastModifiedAt' ); + const [searchText, setSearchText] = React.useState(''); const onUnregisterGame = React.useCallback( async ( @@ -402,7 +392,7 @@ const CreateSection = ({ projectName: file.fileMetadata.name, projectAuthor: username, // A project is always saved when appearing in the list of recent projects. - isProjectSaved: true, + savedStatus: 'saved', }) ); return game; @@ -550,10 +540,15 @@ const CreateSection = ({ askToCloseProject={askToCloseProject} onSaveProject={onSaveProject} canSaveProject={canSaveProject} - currentPage={currentPage} - onCurrentPageChange={onCurrentPageChange} onDeleteCloudProject={onDeleteCloudProject} onRegisterProject={onRegisterProject} + // Controls + currentPage={currentPage} + setCurrentPage={setCurrentPage} + orderBy={orderBy} + setGamesListOrderBy={setGamesListOrderBy} + searchText={searchText} + setSearchText={setSearchText} /> {isMobile && limits && hasTooManyCloudProjects && ( { url={url} className={classNames.root} style={styles.icon} - quote={`Try the game I just created with GDevelop.io`} + // Quote has been deprecated by Facebook, we can't fill the text of the share dialog, only the hashtag. hashtag="#gdevelop" > diff --git a/newIDE/app/src/Utils/UseCreateProject.js b/newIDE/app/src/Utils/UseCreateProject.js index 8f4eff82c95c..16cedd3e51b4 100644 --- a/newIDE/app/src/Utils/UseCreateProject.js +++ b/newIDE/app/src/Utils/UseCreateProject.js @@ -171,10 +171,12 @@ const useCreateProject = ({ projectName: currentProject.getName(), projectAuthor: currentProject.getAuthor(), // Project is saved if choosing cloud or local storage provider. - isProjectSaved: + savedStatus: newProjectSetup.storageProvider.internalName === 'LocalFile' || - newProjectSetup.storageProvider.internalName === 'Cloud', + newProjectSetup.storageProvider.internalName === 'Cloud' + ? 'saved' + : 'draft', }) ); await onGameRegistered(); diff --git a/newIDE/app/src/Utils/UseGameAndBuildsManager.js b/newIDE/app/src/Utils/UseGameAndBuildsManager.js index 250e17356d4c..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'; @@ -23,17 +24,17 @@ export const getDefaultRegisterGameProperties = ({ projectId, projectName, projectAuthor, - isProjectSaved, + savedStatus, }: {| projectId: string, projectName: ?string, projectAuthor: ?string, - isProjectSaved: boolean, + savedStatus: SavedStatus, |}) => ({ gameId: projectId, authorName: projectAuthor || 'Unspecified publisher', gameName: projectName || 'Untitled game', - savedStatus: isProjectSaved ? 'saved' : 'draft', + savedStatus, }); export type GameManager = {| @@ -163,7 +164,7 @@ export const useGameManager = ({ 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. - isProjectSaved: false, + savedStatus: 'draft', }) ); diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js index e4ba7bf13d60..c32e889c9c4a 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js @@ -39,6 +39,10 @@ export const NoGamesOrProjects = () => { getRecentProjectFiles: () => projectFiles, }; + const [currentPage, setCurrentPage] = React.useState(1); + const [orderBy, setOrderBy] = React.useState('lastModifiedAt'); + const [searchText, setSearchText] = React.useState(''); + return ( @@ -59,10 +63,14 @@ export const NoGamesOrProjects = () => { onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')} onSaveProject={action('onSaveProject')} onUnregisterGame={action('onUnregisterGame')} - currentPage={1} - onCurrentPageChange={action('onCurrentPageChange')} onDeleteCloudProject={action('onDeleteCloudProject')} onRegisterProject={action('onRegisterProject')} + currentPage={currentPage} + setCurrentPage={setCurrentPage} + orderBy={orderBy} + setGamesListOrderBy={setOrderBy} + searchText={searchText} + setSearchText={setSearchText} /> @@ -89,6 +97,10 @@ export const WithOnlyGames = () => { getRecentProjectFiles: () => projectFiles, }; + const [currentPage, setCurrentPage] = React.useState(1); + const [orderBy, setOrderBy] = React.useState('lastModifiedAt'); + const [searchText, setSearchText] = React.useState(''); + return ( @@ -109,10 +121,14 @@ export const WithOnlyGames = () => { onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')} onSaveProject={action('onSaveProject')} onUnregisterGame={action('onUnregisterGame')} - currentPage={1} - onCurrentPageChange={action('onCurrentPageChange')} onDeleteCloudProject={action('onDeleteCloudProject')} onRegisterProject={action('onRegisterProject')} + currentPage={currentPage} + setCurrentPage={setCurrentPage} + orderBy={orderBy} + setGamesListOrderBy={setOrderBy} + searchText={searchText} + setSearchText={setSearchText} /> @@ -134,6 +150,10 @@ export const WithOnlyProjects = () => { getRecentProjectFiles: () => projectFiles, }; + const [currentPage, setCurrentPage] = React.useState(1); + const [orderBy, setOrderBy] = React.useState('lastModifiedAt'); + const [searchText, setSearchText] = React.useState(''); + return ( @@ -154,10 +174,14 @@ export const WithOnlyProjects = () => { onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')} onSaveProject={action('onSaveProject')} onUnregisterGame={action('onUnregisterGame')} - currentPage={1} - onCurrentPageChange={action('onCurrentPageChange')} onDeleteCloudProject={action('onDeleteCloudProject')} onRegisterProject={action('onRegisterProject')} + currentPage={currentPage} + setCurrentPage={setCurrentPage} + orderBy={orderBy} + setGamesListOrderBy={setOrderBy} + searchText={searchText} + setSearchText={setSearchText} /> @@ -186,6 +210,10 @@ export const WithGamesAndProjects = () => { getRecentProjectFiles: () => projectFiles, }; + const [currentPage, setCurrentPage] = React.useState(1); + const [orderBy, setOrderBy] = React.useState('lastModifiedAt'); + const [searchText, setSearchText] = React.useState(''); + return ( @@ -206,10 +234,14 @@ export const WithGamesAndProjects = () => { onOpenNewProjectSetupDialog={action('onOpenNewProjectSetupDialog')} onSaveProject={action('onSaveProject')} onUnregisterGame={action('onUnregisterGame')} - currentPage={1} - onCurrentPageChange={action('onCurrentPageChange')} onDeleteCloudProject={action('onDeleteCloudProject')} onRegisterProject={action('onRegisterProject')} + currentPage={currentPage} + setCurrentPage={setCurrentPage} + orderBy={orderBy} + setGamesListOrderBy={setOrderBy} + searchText={searchText} + setSearchText={setSearchText} /> 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 100% rename from newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarnings.stories.js rename to newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarningsWidget.stories.js From a3c56ce1784f1f0b4fbd352da4493e85212c12ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Thu, 12 Dec 2024 14:08:00 +0100 Subject: [PATCH 17/26] Add footer & skeleton while loading --- .../src/GameDashboard/GameDashboardCard.js | 10 +-- newIDE/app/src/GameDashboard/GamesList.js | 79 +++++++++++++------ newIDE/app/src/GameDashboard/UseGamesList.js | 2 +- newIDE/app/src/GameDashboard/index.js | 14 ++++ .../HomePage/CreateSection/index.js | 2 +- newIDE/app/src/UI/Slideshow/Slideshow.js | 4 - 6 files changed, 75 insertions(+), 36 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js index 7e479a00360b..50e33596a8ee 100644 --- a/newIDE/app/src/GameDashboard/GameDashboardCard.js +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -54,6 +54,9 @@ 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 = { buttonsContainer: { display: 'flex', @@ -359,12 +362,7 @@ const GameDashboardCard = ({ gameId={game ? game.id : undefined} thumbnailUrl={gameThumbnailUrl} background="light" - width={ - isMobile - ? undefined - : // On medium/large screens, adapt the size to the width of the window. - Math.min(245, Math.max(130, window.innerWidth / 4)) - } + width={getThumbnailWidth({ isMobile })} /> ); diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 1f4095ea59e0..351decce543e 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -5,7 +5,10 @@ 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 GameDashboardCard, { type DashboardItem } from './GameDashboardCard'; +import GameDashboardCard, { + getThumbnailWidth, + type DashboardItem, +} from './GameDashboardCard'; import { ColumnStackLayout, LineStackLayout, @@ -42,6 +45,7 @@ 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'; const electron = optionalRequire('electron'); const isDesktop = !!electron; @@ -51,6 +55,10 @@ const pageSize = 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 OrderBy = 'totalSessions' | 'weeklySessions' | 'lastModifiedAt'; @@ -127,12 +135,13 @@ const getDashboardItemsToDisplay = ({ }: {| project: ?gdProject, currentFileMetadata: ?FileMetadata, - allDashboardItems: Array, + allDashboardItems: ?Array, searchText: string, searchClient: Fuse, currentPage: number, orderBy: OrderBy, -|}): Array => { +|}): ?Array => { + if (!allDashboardItems) return null; let itemsToDisplay: DashboardItem[] = allDashboardItems; if (searchText) { @@ -223,7 +232,7 @@ type Props = {| storageProviders: Array, project: ?gdProject, currentFileMetadata: ?FileMetadata, - games: Array, + games: ?Array, onRefreshGames: () => Promise, onOpenGameManager: ({ game: Game, @@ -291,10 +300,12 @@ const GamesList = ({ AuthenticatedUserContext ); const { isMobile } = useResponsiveWindowSize(); + const gameThumbnailWidth = getThumbnailWidth({ isMobile }); const allRecentProjectFiles = useProjectsListFor(null); - const allDashboardItems: DashboardItem[] = React.useMemo( + const allDashboardItems: ?(DashboardItem[]) = React.useMemo( () => { + if (!games) return null; const projectFilesWithGame = games.map(game => { const projectFiles = allRecentProjectFiles.filter( file => file.fileMetadata.gameId === game.id @@ -311,7 +322,9 @@ const GamesList = ({ [games, allRecentProjectFiles] ); - const totalNumberOfPages = Math.ceil(allDashboardItems.length / pageSize); + const totalNumberOfPages = allDashboardItems + ? Math.ceil(allDashboardItems.length / pageSize) + : 1; const onCurrentPageChange = React.useCallback( newPage => { const minPage = 1; @@ -329,7 +342,7 @@ const GamesList = ({ const searchClient = React.useMemo( () => - new Fuse(allDashboardItems, { + new Fuse(allDashboardItems || [], { ...sharedFuseConfiguration, keys: [ { name: 'game.gameName', weight: 1 }, @@ -339,9 +352,10 @@ const GamesList = ({ [allDashboardItems] ); - const [displayedDashboardItems, setDisplayedDashboardItems] = React.useState< - Array - >( + const [ + displayedDashboardItems, + setDisplayedDashboardItems, + ] = React.useState>( getDashboardItemsToDisplay({ project, currentFileMetadata, @@ -443,18 +457,20 @@ const GamesList = ({ Games - {allDashboardItems.length > 0 && ( - -
- -
-
- )} + +
+ +
+
- {allDashboardItems.length > 0 && ( + {allDashboardItems && allDashboardItems.length > 0 && ( )} - {displayedDashboardItems.length > 0 ? ( + {!displayedDashboardItems && + Array.from({ length: pageSize }).map((_, i) => ( + + + + ))} + {displayedDashboardItems && displayedDashboardItems.length > 0 ? ( displayedDashboardItems .map((dashboardItem, index) => { const game = dashboardItem.game; diff --git a/newIDE/app/src/GameDashboard/UseGamesList.js b/newIDE/app/src/GameDashboard/UseGamesList.js index 6232b2e5c721..771c7d5712b2 100644 --- a/newIDE/app/src/GameDashboard/UseGamesList.js +++ b/newIDE/app/src/GameDashboard/UseGamesList.js @@ -33,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; diff --git a/newIDE/app/src/GameDashboard/index.js b/newIDE/app/src/GameDashboard/index.js index 38dad0ee54d2..f2c419d2a862 100644 --- a/newIDE/app/src/GameDashboard/index.js +++ b/newIDE/app/src/GameDashboard/index.js @@ -57,6 +57,16 @@ import PublicGamePropertiesDialog, { import useAlertDialog from '../UI/Alert/useAlertDialog'; import { showErrorBox } from '../UI/Messages/MessageBox'; import ProjectsWidget from './Widgets/ProjectsWidget'; +import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; + +const styles = { + mobileFooter: { + height: 150, + }, + desktopFooter: { + height: 200, + }, +}; export type GameDetailsTab = | 'details' @@ -113,6 +123,7 @@ const GameDashboard = ({ initialWidgetToScrollTo, }: Props) => { const grid = React.useRef(null); + const { isMobile } = useResponsiveWindowSize(); const [widgetToScrollTo, setWidgetToScrollTo] = React.useState( initialWidgetToScrollTo ); @@ -599,6 +610,9 @@ const GameDashboard = ({ onSeeAllBuilds={() => setCurrentView('builds')} />
+
)} diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index 58659876ca2f..c32a8d8e09c2 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -517,7 +517,7 @@ const CreateSection = ({ Date: Thu, 12 Dec 2024 15:41:33 +0100 Subject: [PATCH 18/26] Fix showing last week chart data when no sessions in the last week --- .../GameDashboard/Widgets/AnalyticsWidget.js | 17 ++++++++++++++--- newIDE/app/src/GameDashboard/index.js | 18 +++++++++++++----- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js b/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js index 56e9b8287d9f..65ece341d105 100644 --- a/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/AnalyticsWidget.js @@ -36,14 +36,25 @@ 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 ( <> diff --git a/newIDE/app/src/GameDashboard/index.js b/newIDE/app/src/GameDashboard/index.js index f2c419d2a862..ee848a8e900c 100644 --- a/newIDE/app/src/GameDashboard/index.js +++ b/newIDE/app/src/GameDashboard/index.js @@ -58,6 +58,8 @@ import useAlertDialog from '../UI/Alert/useAlertDialog'; import { showErrorBox } from '../UI/Messages/MessageBox'; import ProjectsWidget from './Widgets/ProjectsWidget'; import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; +import { formatISO, subDays } from 'date-fns'; +import { daysShownForYear } from './GameAnalyticsEvaluator'; const styles = { mobileFooter: { @@ -152,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') @@ -474,7 +476,7 @@ const GameDashboard = ({ getAuthorizationHeader, profile.id, game.id, - oneWeekAgo.current.toISOString() + lastYearIsoDate ), listGameActiveLeaderboards(getAuthorizationHeader, profile.id, game.id), getRecommendedMarketingPlan(getAuthorizationHeader, { @@ -489,7 +491,13 @@ const GameDashboard = ({ setLeaderboards(leaderboards); setRecommendedMarketingPlan(recommendedMarketingPlan); }, - [fetchGameFeaturings, game.id, getAuthorizationHeader, profile] + [ + fetchGameFeaturings, + game.id, + getAuthorizationHeader, + profile, + lastYearIsoDate, + ] ); React.useEffect( From 5c1ecf6c2769347cac9d89c7e64677735bdbdc7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:12:52 +0100 Subject: [PATCH 19/26] Fix bunch of exact types --- newIDE/app/src/AssetStore/ShopTiles.js | 2 +- .../CommandPalette/AutocompletePicker.js | 2 +- .../src/Debugger/Profiler/MeasuresTable.js | 8 ++- .../src/EventsBasedBehaviorEditor/index.js | 3 +- .../app/src/EventsSheet/EventsTree/index.js | 8 +-- .../src/ExportAndShare/Builds/BuildCard.js | 2 +- .../ShareDialog/ExportLauncher.js | 2 +- .../ExportAndShare/ShareDialog/PublishHome.js | 2 +- .../GameDashboard/Feedbacks/GameFeedback.js | 2 +- .../src/GameDashboard/GameAnalyticsCharts.js | 2 +- .../src/GameDashboard/GameDashboardCard.js | 64 +++++++++++-------- .../InstancesEditor/InstancesList/index.js | 6 +- newIDE/app/src/InstancesEditor/index.js | 2 +- .../HomePage/InAppTutorials/GuidedLessons.js | 6 +- .../LearnSection/EducationCurriculumLesson.js | 2 +- .../HomePage/TeamSection/TeamSection.spec.js | 2 +- newIDE/app/src/MainFrame/index.js | 4 +- .../Editors/SpriteEditor/SpritesList.js | 2 +- .../src/Profile/AuthenticatedUserProvider.js | 2 +- .../src/QuickCustomization/QuickPublish.js | 2 +- newIDE/app/src/UI/Dialog.js | 6 +- newIDE/app/src/UI/GravatarUrl.js | 2 +- .../app/src/Utils/GDevelopServices/Build.js | 2 +- .../app/src/Utils/GDevelopServices/Project.js | 2 +- newIDE/app/src/Utils/UseDisplayNewFeature.js | 4 +- .../AssetStore/AssetDetails.stories.js | 2 +- .../AssetPackInstallDialog.stories.js | 2 +- .../AssetStore/AssetStore.stories.js | 2 +- .../CustomObjectPackResults.stories.js | 2 +- .../ResourceStore/ResourceStore.stories.js | 2 +- .../QuickPublish.stories.js | 2 +- 31 files changed, 89 insertions(+), 64 deletions(-) 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/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/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js index 50e33596a8ee..c7784e5aa184 100644 --- a/newIDE/app/src/GameDashboard/GameDashboardCard.js +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -54,7 +54,7 @@ import { Tooltip } from '@material-ui/core'; const electron = optionalRequire('electron'); const path = optionalRequire('path'); -export const getThumbnailWidth = ({ isMobile }: { isMobile: boolean }) => +export const getThumbnailWidth = ({ isMobile }: {| isMobile: boolean |}) => isMobile ? undefined : Math.min(245, Math.max(130, window.innerWidth / 4)); const styles = { @@ -176,6 +176,7 @@ const GameDashboardCard = ({ : null; const authenticatedUser = React.useContext(AuthenticatedUserContext); + const { profile, onOpenLoginDialog } = authenticatedUser; const { removeRecentProjectFile } = React.useContext(PreferencesContext); const { showAlert, @@ -193,9 +194,8 @@ const GameDashboardCard = ({ const gameName = game ? game.gameName : projectFileMetadataAndStorageProviderName - ? projectFileMetadataAndStorageProviderName.fileMetadata.name || - 'Unknown game' - : 'Unknown game'; + ? projectFileMetadataAndStorageProviderName.fileMetadata.name + : null; const { isMobile, windowSize } = useResponsiveWindowSize(); const isSmallOrMediumScreen = @@ -268,7 +268,7 @@ const GameDashboardCard = ({ const renderTitle = () => ( - {gameName} + {gameName || Unknown game} {projectsList.length > 0 && game && ( <> @@ -358,7 +358,7 @@ const GameDashboardCard = ({ const renderThumbnail = () => ( ); - const buildOpenContextMenu = ( - i18n: I18nType, - projectsList: FileMetadataAndStorageProviderName[] + const buildOpenProjectContextMenu = ( + i18n: I18nType ): Array => { const actions = []; if (projectsList.length > 1) { @@ -384,13 +383,20 @@ const GameDashboardCard = ({ click: () => onOpenProject(fileMetadataAndStorageProviderName), }; }), - { type: 'separator' }, - { - label: i18n._(t`See all in the game dashboard`), - click: game ? () => onOpenGameManager({ game }) : undefined, - }, ] ); + + if (game) { + actions.push( + ...[ + { type: 'separator' }, + { + label: i18n._(t`See all in the game dashboard`), + click: () => onOpenGameManager({ game }), + }, + ] + ); + } } return actions; @@ -418,7 +424,11 @@ const GameDashboardCard = ({ } // Management actions. - if (projectsList.length < 2) { + 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({ @@ -426,7 +436,9 @@ const GameDashboardCard = ({ click: () => locateProjectFile(file), }); } - } else { + } + + if (projectsList.length > 1) { // If there are multiple projects, suggest opening the game dashboard. actions.push({ label: i18n._(t`See all projects`), @@ -437,14 +449,15 @@ const GameDashboardCard = ({ // 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 < 2) { + if (isCurrentProjectOpened || projectsList.length > 1) { + // No delete action possible. + } else { if (actions.length > 0) { actions.push({ type: 'separator', }); } - const file = projectsList[0]; actions.push({ label: i18n._(t`Delete`), click: async () => { @@ -473,6 +486,8 @@ const GameDashboardCard = ({ } // 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); @@ -498,8 +513,8 @@ const GameDashboardCard = ({ onOpenGameManager({ game }); return; } else { - if (!authenticatedUser.profile) { - authenticatedUser.onOpenLoginDialog(); + if (!profile) { + onOpenLoginDialog(); return; } const answer = await showConfirmation({ @@ -523,11 +538,12 @@ const GameDashboardCard = ({ onRegisterProject, projectsList, onRefreshGames, - authenticatedUser, + onOpenLoginDialog, + profile, ] ); - const renderButtons = ({ fullWidth }: { fullWidth: boolean }) => { + const renderButtons = ({ fullWidth }: {| fullWidth: boolean |}) => { const openProjectLabel = isCurrentProjectOpened ? ( Save ) : ( @@ -568,9 +584,7 @@ const GameDashboardCard = ({ fullWidth={fullWidth} label={openProjectLabel} onClick={mainAction} - buildMenuTemplate={i18n => - buildOpenContextMenu(i18n, projectsList) - } + buildMenuTemplate={i18n => buildOpenProjectContextMenu(i18n)} disabled={disabled || (isCurrentProjectOpened && !canSaveProject)} /> )} 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/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/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/index.js b/newIDE/app/src/MainFrame/index.js index 7582d18ca737..7787843ee31b 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -1793,7 +1793,7 @@ const MainFrame = (props: Props) => { { openEventsEditor, openSceneEditor, - }: { openEventsEditor: boolean, openSceneEditor: boolean } + }: {| openEventsEditor: boolean, openSceneEditor: boolean |} ): EditorTabsState => { const sceneEditorOptions = getEditorOpeningOptions({ kind: 'layout', @@ -1821,7 +1821,7 @@ const MainFrame = (props: Props) => { { openEventsEditor = true, openSceneEditor = true, - }: { openEventsEditor: boolean, openSceneEditor: boolean } = {}, + }: {| openEventsEditor: boolean, openSceneEditor: boolean |} = {}, editorTabs?: EditorTabsState ): void => { setState(state => ({ 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/QuickCustomization/QuickPublish.js b/newIDE/app/src/QuickCustomization/QuickPublish.js index f53c81fc738f..5a7a946fd7e3 100644 --- a/newIDE/app/src/QuickCustomization/QuickPublish.js +++ b/newIDE/app/src/QuickCustomization/QuickPublish.js @@ -132,7 +132,7 @@ export const QuickPublish = ({ ); const onExportSucceeded = React.useCallback( - async ({ build }: { build: ?Build }) => { + async ({ build }: {| build: ?Build |}) => { try { if (profile && game && build) { setExportState('updating-game'); 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/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/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/Project.js b/newIDE/app/src/Utils/GDevelopServices/Project.js index 5bbe8b8f4e6d..b47acc72b777 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Project.js +++ b/newIDE/app/src/Utils/GDevelopServices/Project.js @@ -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/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/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/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( From a673843194dd4780f8b20a58595668904a8be145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:38:54 +0100 Subject: [PATCH 20/26] More fixes --- .../app/src/AssetStore/ExampleStore/index.js | 6 ++-- .../src/GameDashboard/GameDashboardCard.js | 4 +-- .../Monetization/UserEarningsWidget.js | 36 +++++++++---------- .../GameDashboard/Widgets/DashboardWidget.js | 3 +- .../HomePage/CreateSection/index.js | 2 +- 5 files changed, 24 insertions(+), 27 deletions(-) diff --git a/newIDE/app/src/AssetStore/ExampleStore/index.js b/newIDE/app/src/AssetStore/ExampleStore/index.js index a08942b41e1c..09b8bdcfebd0 100644 --- a/newIDE/app/src/AssetStore/ExampleStore/index.js +++ b/newIDE/app/src/AssetStore/ExampleStore/index.js @@ -76,7 +76,7 @@ const ExampleStore = ({ rowToInsert, hideSearch, }: Props) => { - const authenticatedUser = React.useContext(AuthenticatedUserContext); + const { receivedGameTemplates } = React.useContext(AuthenticatedUserContext); const { exampleShortHeadersSearchResults, fetchExamplesAndFilters, @@ -140,7 +140,7 @@ const ExampleStore = ({ const resultTiles: React.Node[] = React.useMemo( () => { return getExampleAndTemplateTiles({ - receivedGameTemplates: authenticatedUser.receivedGameTemplates, + receivedGameTemplates, privateGameTemplateListingDatas: privateGameTemplateListingDatasSearchResults ? privateGameTemplateListingDatasSearchResults .map(({ item }) => item) @@ -187,7 +187,7 @@ const ExampleStore = ({ }).allGridItems; }, [ - authenticatedUser, + receivedGameTemplates, privateGameTemplateListingDatasSearchResults, exampleShortHeadersSearchResults, onSelectPrivateGameTemplateListingData, diff --git a/newIDE/app/src/GameDashboard/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js index c7784e5aa184..600127596bf6 100644 --- a/newIDE/app/src/GameDashboard/GameDashboardCard.js +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -555,8 +555,8 @@ const GameDashboardCard = ({ ? () => onOpenProject(projectsList[0]) : () => { showAlert({ - title: t`No project found`, - message: t`We couldn't find a project for this game. You may have saved it in a different location? You can open it manually to get it linked to this game.`, + title: t`No project to open`, + message: t`Looks like your project isn't there!\n\nYou may be using a different computer or opening GDevelop on the web and your project is saved locally.`, }); }; diff --git a/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js b/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js index 4efe862f6f92..8f0bf8116f60 100644 --- a/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js +++ b/newIDE/app/src/GameDashboard/Monetization/UserEarningsWidget.js @@ -12,11 +12,14 @@ 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'; +import Tooltip from '@material-ui/core/Tooltip'; import CreditOutDialog from './CashOutDialog'; import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; import DashboardWidget from '../Widgets/DashboardWidget'; import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; +import { getHelpLink } from '../../Utils/HelpLink'; + +const monetizationHelpLink = getHelpLink('/monetization'); const styles = { separator: { @@ -51,7 +54,7 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { ?'cash' | 'credits' >(null); - const fetchUserEarningsBalance = React.useCallback( + const animateEarnings = React.useCallback( async () => { if (!userEarningsBalance) return; @@ -64,11 +67,11 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { const steps = 30; const intervalTime = duration / steps; - const milliUsdIncrement = (targetMilliUsd - earningsInMilliUsd) / steps; - const creditsIncrement = (targetCredits - earningsInCredits) / steps; + const milliUsdIncrement = targetMilliUsd / steps; + const creditsIncrement = targetCredits / steps; - let currentMilliUsd = earningsInMilliUsd; - let currentCredits = earningsInCredits; + let currentMilliUsd = 0; + let currentCredits = 0; let step = 0; intervalValuesUpdate.current = setInterval(() => { @@ -91,16 +94,14 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { setError(error); } }, - [userEarningsBalance, earningsInMilliUsd, earningsInCredits] + [userEarningsBalance] ); React.useEffect( () => { - fetchUserEarningsBalance(); + animateEarnings(); }, - // Fetch the earnings once on mount. - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [animateEarnings] ); React.useEffect( @@ -115,8 +116,7 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { const onCashOrCreditOut = React.useCallback( async () => { - await onRefreshEarningsBalance(); - await onRefreshLimits(); + await Promise.all([onRefreshEarningsBalance(), onRefreshLimits()]); }, [onRefreshEarningsBalance, onRefreshLimits] ); @@ -129,7 +129,7 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { - Can't load the total earnings. Verify your internet connection or try + Can't load your game earnings. Verify your internet connection or try again later. @@ -143,12 +143,8 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { > - Window.openExternalURL( - 'https://wiki.gdevelop.io/gdevelop5/monetization/' - ) - } + href={monetizationHelpLink} + onClick={() => Window.openExternalURL(monetizationHelpLink)} > Learn about revenue on gd.games diff --git a/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js b/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js index 4b027f046572..3e8a1441f1a7 100644 --- a/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/DashboardWidget.js @@ -7,6 +7,7 @@ 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 padding = 16; const fixedHeight = 300; @@ -53,7 +54,7 @@ const DashboardWidget = ({ }: Props) => { const { isMobile } = useResponsiveWindowSize(); return ( - + Date: Thu, 12 Dec 2024 18:32:47 +0100 Subject: [PATCH 21/26] New round of improvements --- .../src/GameDashboard/GameDashboardCard.js | 26 +++++++++------ newIDE/app/src/GameDashboard/GameHeader.js | 10 ++++-- newIDE/app/src/GameDashboard/GamesList.js | 33 ++++++++++++------- .../Monetization/UserEarningsWidget.js | 10 +++--- .../src/GameDashboard/Wallet/WalletWidget.js | 10 +++--- .../GameDashboard/Widgets/AnalyticsWidget.js | 2 +- .../src/GameDashboard/Widgets/BuildsWidget.js | 2 +- .../GameDashboard/Widgets/DashboardWidget.js | 28 ++++++++++++++-- .../GameDashboard/Widgets/FeedbackWidget.js | 2 +- .../GameDashboard/Widgets/ProjectsWidget.js | 2 +- .../GameDashboard/Widgets/ServicesWidget.js | 2 +- .../HomePage/CreateSection/index.js | 27 ++++++++++----- .../Preferences/PreferencesContext.js | 9 +++++ .../Preferences/PreferencesProvider.js | 14 ++++++++ newIDE/app/src/UI/Layout.js | 1 - newIDE/app/src/Utils/GDevelopServices/Game.js | 2 +- .../GameDashboard/GamesList.stories.js | 12 ------- .../UserEarningsWidget.stories.js | 10 +++--- 18 files changed, 134 insertions(+), 68 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js index 600127596bf6..76500018bfa2 100644 --- a/newIDE/app/src/GameDashboard/GameDashboardCard.js +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -84,13 +84,19 @@ const locateProjectFile = (file: FileMetadataAndStorageProviderName) => { }; const getFileNameWithoutExtensionFromPath = (path: string) => { - const fileName = path.split('/').pop(); - return fileName - ? fileName - .split('.') - .slice(0, -1) - .join('.') - : ''; + // 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 = ( @@ -197,7 +203,7 @@ const GameDashboardCard = ({ ? projectFileMetadataAndStorageProviderName.fileMetadata.name : null; - const { isMobile, windowSize } = useResponsiveWindowSize(); + const { isMobile, windowSize, isLandscape } = useResponsiveWindowSize(); const isSmallOrMediumScreen = windowSize === 'small' || windowSize === 'medium'; const gdevelopTheme = React.useContext(GDevelopThemeContext); @@ -318,7 +324,7 @@ const GameDashboardCard = ({ Last edited: - {i18n.date(game.updatedAt * 1000)} + {i18n.date((game.updatedAt || 0) * 1000)} ) : null; @@ -609,7 +615,7 @@ const GameDashboardCard = ({ isHighlighted={isCurrentProjectOpened} padding={isMobile ? 8 : 16} > - {isMobile ? ( + {isMobile && !isLandscape ? ( diff --git a/newIDE/app/src/GameDashboard/GameHeader.js b/newIDE/app/src/GameDashboard/GameHeader.js index fa023a2ae1f9..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 ( - +
@@ -153,7 +157,7 @@ const GameHeader = ({ ); - if (isMobile) { + if (isMobile && !isLandscape) { return ( {({ i18n }) => ( diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index 351decce543e..b07b247d429f 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -46,6 +46,7 @@ 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; @@ -61,7 +62,10 @@ const styles = { }, }; -export type OrderBy = 'totalSessions' | 'weeklySessions' | 'lastModifiedAt'; +export type GamesDashboardOrderBy = + | 'totalSessions' + | 'weeklySessions' + | 'lastModifiedAt'; const totalSessionsSort = ( itemA: DashboardItem, @@ -87,7 +91,7 @@ const getDashboardItemLastModifiedAt = (item: DashboardItem): number => { ); } // Then the game, if any. - return (item.game && item.game.updatedAt * 1000) || 0; + return (item.game && (item.game.updatedAt || 0) * 1000) || 0; }; const lastModifiedAtSort = ( @@ -139,7 +143,7 @@ const getDashboardItemsToDisplay = ({ searchText: string, searchClient: Fuse, currentPage: number, - orderBy: OrderBy, + orderBy: GamesDashboardOrderBy, |}): ?Array => { if (!allDashboardItems) return null; let itemsToDisplay: DashboardItem[] = allDashboardItems; @@ -163,8 +167,9 @@ const getDashboardItemsToDisplay = ({ ? itemsToDisplay.sort(lastModifiedAtSort) : itemsToDisplay; - // If a project is opened and no search is performed, display it first. - if (project) { + // 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 @@ -263,8 +268,6 @@ type Props = {| // Controls currentPage: number, setCurrentPage: (currentPage: number) => void, - orderBy: OrderBy, - setGamesListOrderBy: (orderBy: OrderBy) => void, searchText: string, setSearchText: (searchText: string) => void, |}; @@ -291,14 +294,17 @@ const GamesList = ({ // Make the page controlled, so that it can be saved when navigating to a game. currentPage, setCurrentPage, - orderBy, - setGamesListOrderBy, searchText, setSearchText, }: Props) => { const { cloudProjects, profile, onCloudProjectsChanged } = React.useContext( AuthenticatedUserContext ); + const { + values: { gamesDashboardOrderBy: orderBy }, + setGamesDashboardOrderBy, + } = React.useContext(PreferencesContext); + const { isMobile } = useResponsiveWindowSize(); const gameThumbnailWidth = getThumbnailWidth({ isMobile }); @@ -503,7 +509,12 @@ const GamesList = ({ {allDashboardItems && allDashboardItems.length > 0 && ( - + // $FlowFixMe - setGamesListOrderBy(value) + setGamesDashboardOrderBy(value) } > { +const UserEarningsWidget = ({ size }: Props) => { const { userEarningsBalance, onRefreshEarningsBalance, @@ -227,7 +229,7 @@ const UserEarningsWidget = ({ fullWidth }: Props) => { return ( <> Game earnings} widgetName="earnings" > diff --git a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js index e592b6add0aa..dda51811440e 100644 --- a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js +++ b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js @@ -1,7 +1,9 @@ // @flow import * as React from 'react'; -import DashboardWidget from '../Widgets/DashboardWidget'; +import DashboardWidget, { + type DashboardWidgetSize, +} from '../Widgets/DashboardWidget'; import { ColumnStackLayout } from '../../UI/Layout'; import Coin from '../../Credits/Icons/Coin'; import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; @@ -11,14 +13,14 @@ import { Trans } from '@lingui/macro'; type Props = {| onOpenProfile: () => void, - fullWidth?: boolean, + size: DashboardWidgetSize, showRandomBadge?: boolean, showAllBadges?: boolean, |}; const WalletWidget = ({ onOpenProfile, - fullWidth, + size, showRandomBadge, showAllBadges, }: Props) => { @@ -32,7 +34,7 @@ const WalletWidget = ({ const creditsAvailable = limits ? limits.credits.userBalance.amount : 0; return ( Wallet} topRightAction={ { {({ i18n }) => ( Analytics} topRightAction={ diff --git a/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js b/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js index e582b3274f74..4c74a9185b5b 100644 --- a/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/BuildsWidget.js @@ -32,7 +32,7 @@ const BuildsWidget = ({ builds, onSeeAllBuilds }: Props) => { return ( Exports} topRightAction={ { + 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' @@ -37,7 +54,7 @@ type Props = {| title: React.Node, topRightAction?: React.Node, renderSubtitle?: ?() => React.Node, - gridSize: number, + widgetSize: DashboardWidgetSize, children?: React.Node, minHeight?: boolean, widgetName: GameDashboardWidgetName, @@ -46,7 +63,7 @@ type Props = {| const DashboardWidget = ({ title, topRightAction, - gridSize, + widgetSize, renderSubtitle, children, minHeight, @@ -54,7 +71,12 @@ const DashboardWidget = ({ }: Props) => { const { isMobile } = useResponsiveWindowSize(); return ( - + {({ i18n }) => ( Feedbacks} topRightAction={ !feedbacks || feedbacks.length === 0 ? null : ( diff --git a/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js b/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js index 81b99dd8fa41..b1463521f180 100644 --- a/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/ProjectsWidget.js @@ -29,7 +29,7 @@ type Props = {| const ProjectsWidget = (props: Props) => { return ( Projects} widgetName="projects" > diff --git a/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js b/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js index 02b791e63d0f..7b161d7c10d6 100644 --- a/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js +++ b/newIDE/app/src/GameDashboard/Widgets/ServicesWidget.js @@ -39,7 +39,7 @@ const ServicesWidget = ({ ); return ( Player services} widgetName="services" > diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index 138e68bbcf80..156d288d3811 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -6,7 +6,7 @@ import { I18n as I18nType } from '@lingui/core'; import SectionContainer, { SectionRow } from '../SectionContainer'; import ErrorBoundary from '../../../../UI/ErrorBoundary'; import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext'; -import GamesList, { type OrderBy } from '../../../../GameDashboard/GamesList'; +import GamesList from '../../../../GameDashboard/GamesList'; import { deleteGame, registerGame, @@ -198,9 +198,6 @@ const CreateSection = ({ ); const [currentPage, setCurrentPage] = React.useState(1); - const [orderBy, setGamesListOrderBy] = React.useState( - 'lastModifiedAt' - ); const [searchText, setSearchText] = React.useState(''); const onUnregisterGame = React.useCallback( @@ -500,17 +497,31 @@ const CreateSection = ({ {hidePerformanceDashboard ? null : hasAProjectOpenedOrSavedOrGameRegistered ? ( - + ) : ( - + )} @@ -545,8 +556,6 @@ const CreateSection = ({ // Controls currentPage={currentPage} setCurrentPage={setCurrentPage} - orderBy={orderBy} - setGamesListOrderBy={setGamesListOrderBy} searchText={searchText} setSearchText={setSearchText} /> diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js index 9c07b8e8d176..83bc869bebb1 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js @@ -10,6 +10,7 @@ import { type FileMetadataAndStorageProviderName } from '../../ProjectsStorage'; import { type ShortcutMap } from '../../KeyboardShortcuts/DefaultShortcuts'; import { type CommandName } from '../../CommandPalette/CommandsList'; import { type EditorTabsPersistedState } from '../EditorTabs/EditorTabsHandler'; +import { type GamesDashboardOrderBy } from '../../GameDashboard/GamesList'; import optionalRequire from '../../Utils/OptionalRequire'; import { findDefaultFolder } from '../../ProjectsStorage/LocalFileStorageProvider/LocalPathFinder'; import { isWebGLSupported } from '../../Utils/WebGL'; @@ -234,6 +235,7 @@ export type PreferencesValues = {| editorStateByProject: { [string]: { editorTabs: EditorTabsPersistedState } }, fetchPlayerTokenForPreviewAutomatically: boolean, previewCrashReportUploadLevel: string, + gamesDashboardOrderBy: GamesDashboardOrderBy, takeScreenshotOnPreview: boolean, |}; @@ -333,6 +335,9 @@ export type Preferences = {| ) => void, setFetchPlayerTokenForPreviewAutomatically: (enabled: boolean) => void, setPreviewCrashReportUploadLevel: (level: string) => void, + setGamesDashboardOrderBy: ( + orderBy: 'lastModifiedAt' | 'totalSessions' | 'weeklySessions' + ) => void, setTakeScreenshotOnPreview: (enabled: boolean) => void, |}; @@ -389,6 +394,7 @@ export const initialPreferences = { editorStateByProject: {}, fetchPlayerTokenForPreviewAutomatically: true, previewCrashReportUploadLevel: 'exclude-javascript-code-events', + gamesDashboardOrderBy: 'lastModifiedAt', takeScreenshotOnPreview: true, }, setLanguage: () => {}, @@ -460,6 +466,9 @@ export const initialPreferences = { setEditorStateForProject: (projectId, editorState) => {}, setFetchPlayerTokenForPreviewAutomatically: (enabled: boolean) => {}, setPreviewCrashReportUploadLevel: (level: string) => {}, + 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 c8747e31fed7..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,6 +197,7 @@ export default class PreferencesProvider extends React.Component { setPreviewCrashReportUploadLevel: this._setPreviewCrashReportUploadLevel.bind( this ), + setGamesDashboardOrderBy: this._setGamesDashboardOrderBy.bind(this), setTakeScreenshotOnPreview: this._setTakeScreenshotOnPreview.bind(this), }; @@ -1007,6 +1009,18 @@ export default class PreferencesProvider extends React.Component { ); } + _setGamesDashboardOrderBy(newValue: GamesDashboardOrderBy) { + this.setState( + state => ({ + values: { + ...state.values, + gamesDashboardOrderBy: newValue, + }, + }), + () => this._persistValuesToLocalStorage(this.state) + ); + } + _setTakeScreenshotOnPreview(newValue: boolean) { this.setState( state => ({ diff --git a/newIDE/app/src/UI/Layout.js b/newIDE/app/src/UI/Layout.js index 1b4cbf81d866..8efeee6a1e8d 100644 --- a/newIDE/app/src/UI/Layout.js +++ b/newIDE/app/src/UI/Layout.js @@ -171,7 +171,6 @@ export const ResponsiveLineStackLayout = ({ diff --git a/newIDE/app/src/Utils/GDevelopServices/Game.js b/newIDE/app/src/Utils/GDevelopServices/Game.js index 793d60115123..2fcd7169e427 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Game.js +++ b/newIDE/app/src/Utils/GDevelopServices/Game.js @@ -54,7 +54,7 @@ export type Game = {| categories?: string[], authorName: string, // this corresponds to the publisher name createdAt: number, - updatedAt: number, + updatedAt?: number, // Some old games don't have this field publicWebBuildId?: ?string, description?: string, thumbnailUrl?: string, diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js index c32e889c9c4a..6ac086e6ba8f 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js @@ -40,7 +40,6 @@ export const NoGamesOrProjects = () => { }; const [currentPage, setCurrentPage] = React.useState(1); - const [orderBy, setOrderBy] = React.useState('lastModifiedAt'); const [searchText, setSearchText] = React.useState(''); return ( @@ -67,8 +66,6 @@ export const NoGamesOrProjects = () => { onRegisterProject={action('onRegisterProject')} currentPage={currentPage} setCurrentPage={setCurrentPage} - orderBy={orderBy} - setGamesListOrderBy={setOrderBy} searchText={searchText} setSearchText={setSearchText} /> @@ -98,7 +95,6 @@ export const WithOnlyGames = () => { }; const [currentPage, setCurrentPage] = React.useState(1); - const [orderBy, setOrderBy] = React.useState('lastModifiedAt'); const [searchText, setSearchText] = React.useState(''); return ( @@ -125,8 +121,6 @@ export const WithOnlyGames = () => { onRegisterProject={action('onRegisterProject')} currentPage={currentPage} setCurrentPage={setCurrentPage} - orderBy={orderBy} - setGamesListOrderBy={setOrderBy} searchText={searchText} setSearchText={setSearchText} /> @@ -151,7 +145,6 @@ export const WithOnlyProjects = () => { }; const [currentPage, setCurrentPage] = React.useState(1); - const [orderBy, setOrderBy] = React.useState('lastModifiedAt'); const [searchText, setSearchText] = React.useState(''); return ( @@ -178,8 +171,6 @@ export const WithOnlyProjects = () => { onRegisterProject={action('onRegisterProject')} currentPage={currentPage} setCurrentPage={setCurrentPage} - orderBy={orderBy} - setGamesListOrderBy={setOrderBy} searchText={searchText} setSearchText={setSearchText} /> @@ -211,7 +202,6 @@ export const WithGamesAndProjects = () => { }; const [currentPage, setCurrentPage] = React.useState(1); - const [orderBy, setOrderBy] = React.useState('lastModifiedAt'); const [searchText, setSearchText] = React.useState(''); return ( @@ -238,8 +228,6 @@ export const WithGamesAndProjects = () => { onRegisterProject={action('onRegisterProject')} currentPage={currentPage} setCurrentPage={setCurrentPage} - orderBy={orderBy} - setGamesListOrderBy={setOrderBy} searchText={searchText} setSearchText={setSearchText} /> diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarningsWidget.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarningsWidget.stories.js index 6e62434f1835..14858fe78eaa 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarningsWidget.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/Monetization/UserEarningsWidget.stories.js @@ -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 ( - + ); }; From 949b8f857fc1943be53b833db57f31108491537a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:11:54 +0100 Subject: [PATCH 22/26] More fixes --- newIDE/app/src/AssetStore/AssetsHome.js | 4 +- .../src/GameDashboard/GameDashboardCard.js | 35 ++- .../src/GameDashboard/Wallet/WalletWidget.js | 17 +- .../HomePage/CreateSection/index.js | 10 +- .../{EarnBadges.js => EarnCredits.js} | 278 +++++++++++++----- newIDE/app/src/MainFrame/index.js | 14 + 6 files changed, 250 insertions(+), 108 deletions(-) rename newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/{EarnBadges.js => EarnCredits.js} (50%) 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 && ( - 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, @@ -288,19 +293,23 @@ const GameDashboardCard = ({ ) } > - - onOpenGameManager({ game, widgetToScrollTo: 'projects' }) - } - icon={} - label={ - - {projectsList.length} - - } - disabled={disabled} - style={styles.projectFilesButton} - /> + {/* 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} + /> +
)} diff --git a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js index dda51811440e..98f6d039d826 100644 --- a/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js +++ b/newIDE/app/src/GameDashboard/Wallet/WalletWidget.js @@ -7,22 +7,22 @@ import DashboardWidget, { import { ColumnStackLayout } from '../../UI/Layout'; import Coin from '../../Credits/Icons/Coin'; import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; -import { EarnBadges } from '../../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges'; +import { EarnCredits } from '../../MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits'; import TextButton from '../../UI/TextButton'; import { Trans } from '@lingui/macro'; type Props = {| onOpenProfile: () => void, size: DashboardWidgetSize, - showRandomBadge?: boolean, - showAllBadges?: boolean, + showOneItem?: boolean, + showAllItems?: boolean, |}; const WalletWidget = ({ onOpenProfile, size, - showRandomBadge, - showAllBadges, + showOneItem, + showAllItems, }: Props) => { const { profile, @@ -32,6 +32,7 @@ const WalletWidget = ({ onOpenCreateAccountDialog, } = React.useContext(AuthenticatedUserContext); const creditsAvailable = limits ? limits.credits.userBalance.amount : 0; + return ( - diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js index 156d288d3811..df6b7d4144ac 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/CreateSection/index.js @@ -508,7 +508,7 @@ const CreateSection = ({ /> - {!hasAProjectOpenedOrSavedOrGameRegistered ? ( - Publish your first game - ) : ( - Publish a game in 1 minute - )} + Remix a game in 2 minutes - Remix an existing game + Start from a template setShowAllGameTemplates(true)} diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits.js similarity index 50% rename from newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js rename to newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits.js index 7539163c7671..ae1ecf8a6a44 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnBadges.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/EarnCredits.js @@ -18,21 +18,19 @@ import { I18n } from '@lingui/react'; import { useResponsiveWindowSize } from '../../../../UI/Responsive/ResponsiveWindowMeasurer'; import TextButton from '../../../../UI/TextButton'; -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)); +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: { @@ -63,12 +61,93 @@ const styles = { gap: 4, color: 'white', }, - badgeItemPlaceholder: { + 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, @@ -137,84 +216,117 @@ const BadgeItem = ({ ); }; -const allBadgesInfo = [ - { - id: 'github-star', - label: Star GDevelop, - linkUrl: 'https://github.com/4ian/GDevelop', - }, - { - id: 'tiktok-follow', - label: Follow, - linkUrl: 'https://www.tiktok.com/@gdevelop', - }, - { - id: 'twitter-follow', - label: Follow, - linkUrl: 'https://x.com/GDevelopApp', - }, -]; - type Props = {| achievements: ?Array, badges: ?Array, onOpenProfile: () => void, - showRandomBadge?: boolean, - showAllBadges?: boolean, + showRandomItem?: boolean, + showAllItems?: boolean, |}; -export const EarnBadges = ({ +export const EarnCredits = ({ achievements, badges, onOpenProfile, - showRandomBadge, - showAllBadges, + 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( () => { - const allBadgesWithOwnedStatus = allBadgesInfo.map(badgeInfo => ({ - ...badgeInfo, - hasThisBadge: hasBadge(badges, badgeInfo.id), - })); - const notOwnedBadges = allBadgesWithOwnedStatus.filter( - badge => !badge.hasThisBadge - ); - - // If on mobile, and not forcing all badges, show only 1 badge to avoid taking too much space. - if (showRandomBadge || (isMobile && !showAllBadges)) { - if (notOwnedBadges.length === 0) { + 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() * notOwnedBadges.length); - return [notOwnedBadges[randomIndex]]; + const randomIndex = Math.floor(Math.random() * missingBadges.length); + return [missingBadges[randomIndex]]; } return allBadgesWithOwnedStatus; }, - [badges, showRandomBadge, isMobile, showAllBadges] + [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 badges in arrays of two to display them in a responsive way. - const badgesSlicedInArraysOfTwo = React.useMemo( + // Slice items in arrays of two to display them in a responsive way. + const itemsSlicedInArraysOfTwo: CreditItem[][] = React.useMemo( () => { - const slicedBadges = []; - for (let i = 0; i < badgesToShow.length; i += 2) { - slicedBadges.push(badgesToShow.slice(i, i + 2)); + const slicedItems: CreditItem[][] = []; + for (let i = 0; i < allItemsToShow.length; i += 2) { + slicedItems.push(allItemsToShow.slice(i, i + 2)); } - return slicedBadges; + return slicedItems; }, - [badgesToShow] + [allItemsToShow] ); - const onlyOneBadgeDisplayed = badgesToShow.length === 1; + const onlyOneItemDisplayed = allItemsToShow.length === 1; return ( @@ -223,26 +335,36 @@ export const EarnBadges = ({ expand forceMobileLayout={isMobileOrMediumWidth} > - {badgesSlicedInArraysOfTwo.map((badges, index) => ( + {itemsSlicedInArraysOfTwo.map((items, index) => ( - {badges.map(badge => ( - - ))} - {badges.length === 1 && - !onlyOneBadgeDisplayed && - isMobileOrMediumWidth && ( -
- )} + {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/index.js b/newIDE/app/src/MainFrame/index.js index 7787843ee31b..df965d1c3eb0 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -1142,6 +1142,20 @@ const MainFrame = (props: Props) => { 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); From b239676a91467dfb5a22c64ddc84b3ce265a5753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Fri, 13 Dec 2024 17:27:10 +0100 Subject: [PATCH 23/26] Fix AlertDialog properly handling Markdown --- newIDE/app/src/GameDashboard/GameDashboardCard.js | 2 +- newIDE/app/src/UI/Alert/AlertDialog.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GameDashboardCard.js b/newIDE/app/src/GameDashboard/GameDashboardCard.js index cb9f2d6cb021..650ee5ebbaf1 100644 --- a/newIDE/app/src/GameDashboard/GameDashboardCard.js +++ b/newIDE/app/src/GameDashboard/GameDashboardCard.js @@ -571,7 +571,7 @@ const GameDashboardCard = ({ : () => { showAlert({ title: t`No project to open`, - message: t`Looks like your project isn't there!\n\nYou may be using a different computer or opening GDevelop on the web and your project is saved locally.`, + 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.`, }); }; 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)} + + + )} From da46b17ef212c41fa6b7311e0c1ac52bc09998ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Fri, 13 Dec 2024 18:10:40 +0100 Subject: [PATCH 24/26] Assume quick customization game is saved --- newIDE/app/src/GameDashboard/GamesList.js | 20 ++++++++++--------- .../src/QuickCustomization/QuickPublish.js | 3 +++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index b07b247d429f..a1cdee0994ac 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -146,9 +146,16 @@ const getDashboardItemsToDisplay = ({ orderBy: GamesDashboardOrderBy, |}): ?Array => { if (!allDashboardItems) return null; - let itemsToDisplay: DashboardItem[] = allDashboardItems; + 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( getFuseSearchQueryForMultipleKeys(searchText, [ 'game.gameName', @@ -157,7 +164,7 @@ const getDashboardItemsToDisplay = ({ ); itemsToDisplay = searchResults.map(result => result.item); } else { - // Order items first. + // If there is no search, sort the items by the selected order. itemsToDisplay = orderBy === 'totalSessions' ? itemsToDisplay.sort(totalSessionsSort) @@ -218,19 +225,14 @@ const getDashboardItemsToDisplay = ({ } } + // Finally, if there is no search, paginate the results. itemsToDisplay = itemsToDisplay.slice( (currentPage - 1) * pageSize, currentPage * pageSize ); } - return itemsToDisplay.filter( - item => - // Filter out unsaved games, unless they are the opened project. - !item.game || - item.game.savedStatus !== 'draft' || - (project && item.game.id === project.getProjectUuid()) - ); + return itemsToDisplay; }; type Props = {| diff --git a/newIDE/app/src/QuickCustomization/QuickPublish.js b/newIDE/app/src/QuickCustomization/QuickPublish.js index 5a7a946fd7e3..12b4b3ee1419 100644 --- a/newIDE/app/src/QuickCustomization/QuickPublish.js +++ b/newIDE/app/src/QuickCustomization/QuickPublish.js @@ -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); From 55cb9f500e2138b5333219cdf278a66b3267bff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:24:35 +0100 Subject: [PATCH 25/26] Fix padding --- .../MainFrame/EditorContainers/HomePage/HomePageMenuBar.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js index dd94859b73d8..f92edcbcf3c8 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js @@ -19,7 +19,7 @@ import { } from './HomePageMenu'; import { Toolbar, ToolbarGroup } from '../../../UI/Toolbar'; import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; -import { SECTION_PADDING } from './SectionContainer'; +import { SECTION_DESKTOP_SPACING } from './SectionContainer'; const iconSize = 20; const iconButtonPaddingTop = 8; @@ -41,7 +41,7 @@ export const homepageMediumMenuBarWidth = export const styles = { desktopMenu: { - paddingTop: SECTION_PADDING, // To align with the top of the sections + paddingTop: SECTION_DESKTOP_SPACING, // To align with the top of the sections paddingBottom: 10, minWidth: homepageDesktopMenuBarWidth, display: 'flex', From f66f573db4b2673f85705017a9bf23ab2ca5e91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:29:05 +0100 Subject: [PATCH 26/26] Fix learn ratio --- newIDE/app/src/Course/CoursePreviewBanner.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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',