Compare commits

...

1 Commits

Author SHA1 Message Date
Clément Pasteau
959988d147 Allow managing Game from the Projects list in the Build section 2022-12-20 17:27:39 +01:00
14 changed files with 200 additions and 52 deletions

View File

@@ -185,6 +185,7 @@ export const GameCard = ({
<>
<Card
key={game.id}
background={isCurrentGame ? 'dark' : 'medium'}
cardCornerAction={
<ElementWithMenu
element={
@@ -249,6 +250,7 @@ export const GameCard = ({
<GameThumbnail
gameName={game.gameName}
thumbnailUrl={game.thumbnailUrl}
background={isCurrentGame ? 'medium' : 'light'}
/>
</Column>
<Spacer />

View File

@@ -257,9 +257,8 @@ export const GameRegistrationWidget = ({
)}
>
<Trans>
This project is not registered online. Register it now to get access
to metrics collected anonymously, like the number of daily players and
retention of the players after a few days.
The project currently opened is not registered online. Register it now
to get access to leaderboards, player accounts, analytics and more!
</Trans>
</AlertMessage>
);
@@ -269,9 +268,9 @@ export const GameRegistrationWidget = ({
return (
<AlertMessage kind="error">
<Trans>
This project is registered online but you don't have access to it. Ask
the original owner of the game to share it with you to get access to
the game metrics.
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.
</Trans>
</AlertMessage>
);

View File

@@ -19,9 +19,14 @@ const styles = {
type Props = {|
thumbnailUrl?: string,
gameName: string,
background?: 'light' | 'medium' | 'dark',
|};
export const GameThumbnail = ({ thumbnailUrl, gameName }: Props) =>
export const GameThumbnail = ({
thumbnailUrl,
gameName,
background = 'light',
}: Props) =>
thumbnailUrl ? (
<img
src={thumbnailUrl}
@@ -40,7 +45,7 @@ export const GameThumbnail = ({ thumbnailUrl, gameName }: Props) =>
whiteSpace: 'normal',
display: 'flex',
}}
background="light"
background={background}
>
<EmptyMessage>
<Trans>No thumbnail set</Trans>

View File

@@ -1,14 +1,19 @@
// @flow
import { Trans } from '@lingui/macro';
import { t, Trans } from '@lingui/macro';
import * as React from 'react';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import PlaceholderError from '../UI/PlaceholderError';
import { type Game, getGames } from '../Utils/GDevelopServices/Game';
import {
type Game,
getGames,
registerGame,
} from '../Utils/GDevelopServices/Game';
import { GameCard } from './GameCard';
import { ColumnStackLayout } from '../UI/Layout';
import { GameRegistration } from './GameRegistration';
import { GameDetailsDialog, type GameDetailsTab } from './GameDetailsDialog';
import useAlertDialog from '../UI/Alert/useAlertDialog';
type Props = {|
project: ?gdProject,
@@ -29,12 +34,15 @@ export const GamesList = ({
authenticated,
firebaseUser,
getAuthorizationHeader,
profile,
} = React.useContext(AuthenticatedUserContext);
const [openedGame, setOpenedGame] = React.useState<?Game>(null);
const [
openedGameInitialTab,
setOpenedGameInitialTab,
] = React.useState<GameDetailsTab>(initialTab || 'details');
const { showAlert, showConfirmation } = useAlertDialog();
const [isGameRegistering, setIsGameRegistering] = React.useState(false);
const loadGames = React.useCallback(
async () => {
@@ -44,19 +52,82 @@ export const GamesList = ({
setError(null);
const games = await getGames(getAuthorizationHeader, firebaseUser.uid);
setGames(games);
// If a game id was passed, open it.
if (initialGameId) {
const game = games.find(game => game.id === initialGameId);
if (game) {
setOpenedGame(game);
}
}
} catch (error) {
console.error('Error while loading user games.', error);
setError(error);
}
},
[authenticated, firebaseUser, getAuthorizationHeader, initialGameId]
[authenticated, firebaseUser, getAuthorizationHeader]
);
const onRegisterGame = React.useCallback(
async () => {
if (!profile || !project) return;
const { id } = profile;
try {
setIsGameRegistering(true);
await registerGame(getAuthorizationHeader, id, {
gameId: project.getProjectUuid(),
authorName: project.getAuthor() || 'Unspecified publisher',
gameName: project.getName() || 'Untitled game',
templateSlug: project.getTemplateSlug(),
});
await loadGames();
} catch (error) {
console.error('Unable to register the game', error);
if (error.response && error.response.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 {
setIsGameRegistering(false);
}
},
[getAuthorizationHeader, profile, project, showAlert, loadGames]
);
React.useEffect(
() => {
const loadInitialGame = async () => {
// When games are loaded and we have an initial game id, open it.
if (games && initialGameId) {
const game = games.find(game => game.id === initialGameId);
if (game) {
setOpenedGame(game);
} else {
onGameDetailsDialogClose(); // Ensure we reset initial props.
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();
}
}
};
loadInitialGame();
},
[
games,
initialGameId,
onRegisterGame,
onGameDetailsDialogClose,
showConfirmation,
]
);
React.useEffect(
@@ -97,12 +168,14 @@ export const GamesList = ({
return (
<ColumnStackLayout noMargin>
<GameRegistration
project={project}
hideIfRegistered
hideLoader
onGameRegistered={loadGames}
/>
{!isGameRegistering && (
<GameRegistration
project={project}
hideIfRegistered
hideLoader
onGameRegistered={loadGames}
/>
)}
{displayedGames.map(game => (
<GameCard
key={game.id}

View File

@@ -66,6 +66,7 @@ export type RenderEditorContainerProps = {|
onOpenRecentFile: (file: FileMetadataAndStorageProviderName) => void,
onOpenProjectManager: () => void,
onCloseProject: () => Promise<boolean>,
onOpenGamesDashboard: (gameId: string) => void,
// Other dialogs opening:
onCreateProject: (?ExampleShortHeader) => void,

View File

@@ -53,6 +53,9 @@ import BackgroundText from '../../../../UI/BackgroundText';
import Paper from '../../../../UI/Paper';
import PlaceholderError from '../../../../UI/PlaceholderError';
import AlertMessage from '../../../../UI/AlertMessage';
import { ListItemSecondaryAction } from '@material-ui/core';
import IconButton from '../../../../UI/IconButton';
import ThreeDotsMenu from '../../../../UI/CustomSvgIcons/ThreeDotsMenu';
const electron = optionalRequire('electron');
const path = optionalRequire('path');
@@ -94,6 +97,7 @@ type Props = {|
onOpenNewProjectSetupDialog: (?ExampleShortHeader) => void,
onShowAllExamples: () => void,
onSelectExample: (exampleShortHeader: ExampleShortHeader) => void,
onOpenGamesDashboard: (gameId: string) => void,
storageProviders: Array<StorageProvider>,
|};
@@ -142,6 +146,7 @@ const BuildSection = React.forwardRef<Props, BuildSectionInterface>(
onShowAllExamples,
onSelectExample,
onOpenRecentFile,
onOpenGamesDashboard,
storageProviders,
},
ref
@@ -187,6 +192,7 @@ const BuildSection = React.forwardRef<Props, BuildSectionInterface>(
fileIdentifier: cloudProject.id,
lastModifiedDate: Date.parse(cloudProject.lastModifiedAt),
name: cloudProject.name,
gameId: cloudProject.gameId,
},
};
return file;
@@ -268,7 +274,6 @@ const BuildSection = React.forwardRef<Props, BuildSectionInterface>(
];
if (file.storageProviderName === 'Cloud') {
actions = actions.concat([
{ type: 'separator' },
{
label: i18n._(t`Delete`),
click: () => onDeleteCloudProject(i18n, file),
@@ -280,7 +285,6 @@ const BuildSection = React.forwardRef<Props, BuildSectionInterface>(
label: i18n._(t`Show in local folder`),
click: () => locateProjectFile(file),
},
{ type: 'separator' },
{
label: i18n._(t`Remove from list`),
click: () => onRemoveFromRecentFiles(file),
@@ -288,13 +292,24 @@ const BuildSection = React.forwardRef<Props, BuildSectionInterface>(
]);
} else {
actions = actions.concat([
{ type: 'separator' },
{
label: i18n._(t`Remove from list`),
click: () => onRemoveFromRecentFiles(file),
},
]);
}
const gameId = file.fileMetadata.gameId;
if (gameId) {
actions = actions.concat([
{ type: 'separator' },
{
label: i18n._(t`Manage game`),
click: () => onOpenGamesDashboard(gameId),
},
]);
}
return actions;
};
@@ -522,6 +537,20 @@ const BuildSection = React.forwardRef<Props, BuildSectionInterface>(
</Text>
)}
</Column>
<ListItemSecondaryAction>
<IconButton
size="small"
edge="end"
aria-label="menu"
onClick={event => {
// prevent trigerring the click on the list item.
event.stopPropagation();
openContextMenu(event, file);
}}
>
<ThreeDotsMenu />
</IconButton>
</ListItemSecondaryAction>
</LineStackLayout>
) : (
<Column expand>

View File

@@ -40,6 +40,7 @@ type Props = {|
onOpenRecentFile: (file: FileMetadataAndStorageProviderName) => void,
onCreateProject: (ExampleShortHeader | null) => void,
onOpenProjectManager: () => void,
onOpenGamesDashboard: (gameId: string) => void,
// Other dialogs opening:
onOpenHelpFinder: () => void,
@@ -70,6 +71,7 @@ export const HomePage = React.memo<Props>(
canOpen,
onChooseProject,
onOpenRecentFile,
onOpenGamesDashboard,
onOpenNewProjectSetupDialog,
onCreateProject,
onOpenProjectManager,
@@ -214,6 +216,7 @@ export const HomePage = React.memo<Props>(
onCreateProject(exampleShortHeader)
}
onOpenRecentFile={onOpenRecentFile}
onOpenGamesDashboard={onOpenGamesDashboard}
storageProviders={storageProviders}
/>
)}
@@ -261,6 +264,7 @@ export const renderHomePageContainer = (
canOpen={props.canOpen}
onChooseProject={props.onChooseProject}
onOpenRecentFile={props.onOpenRecentFile}
onOpenGamesDashboard={props.onOpenGamesDashboard}
onCreateProject={props.onCreateProject}
onOpenNewProjectSetupDialog={props.onOpenNewProjectSetupDialog}
onOpenProjectManager={props.onOpenProjectManager}

View File

@@ -550,6 +550,7 @@ const MainFrame = (props: Props) => {
setGamesDashboardInitialGameId,
gamesDashboardInitialTab,
setGamesDashboardInitialTab,
openGameDashboard,
} = useOpenInitialDialog({
parameters: {
initialDialog,
@@ -741,9 +742,12 @@ const MainFrame = (props: Props) => {
// (like locally or on Google Drive).
if (onSaveProject) {
preferences.insertRecentProjectFile({
fileMetadata: fileMetadata.name
? fileMetadata
: { ...fileMetadata, name: project.getName() },
fileMetadata: {
...fileMetadata,
name: project.getName(),
gameId: project.getProjectUuid(),
lastModifiedDate: Date.now(),
},
storageProviderName: storageProvider.internalName,
});
}
@@ -1953,7 +1957,6 @@ const MainFrame = (props: Props) => {
// At the end of the promise below, currentProject and storageProvider
// may have changed (if the user opened another project). So we read and
// store their values in variables now.
const projectName = currentProject.getName();
const storageProviderInternalName = newStorageProvider.internalName;
try {
@@ -2004,11 +2007,8 @@ const MainFrame = (props: Props) => {
// Save was done on a new file/location, so save it in the
// recent projects and in the state.
const enrichedFileMetadata = fileMetadata.name
? fileMetadata
: { ...fileMetadata, name: projectName };
const fileMetadataAndStorageProviderName = {
fileMetadata: enrichedFileMetadata,
fileMetadata,
storageProviderName: storageProviderInternalName,
};
preferences.insertRecentProjectFile(fileMetadataAndStorageProviderName);
@@ -2039,7 +2039,7 @@ const MainFrame = (props: Props) => {
// can happen if another project was loaded in the meantime.
setState(state => ({
...state,
currentFileMetadata: enrichedFileMetadata,
currentFileMetadata: fileMetadata,
}));
}
} catch (rawError) {
@@ -2131,7 +2131,6 @@ const MainFrame = (props: Props) => {
// At the end of the promise below, currentProject and storageProvider
// may have changed (if the user opened another project). So we read and
// store their values in variables now.
const projectName = currentProject.getName();
const storageProviderInternalName = getStorageProvider().internalName;
const { wasSaved, fileMetadata } = await onSaveProject(
@@ -2143,12 +2142,9 @@ const MainFrame = (props: Props) => {
console.info(
`Project saved in ${performance.now() - saveStartTime}ms.`
);
const enrichedFileMetadata = fileMetadata.name
? fileMetadata
: { ...fileMetadata, name: projectName };
const fileMetadataAndStorageProviderName = {
fileMetadata: enrichedFileMetadata,
fileMetadata: fileMetadata,
storageProviderName: storageProviderInternalName,
};
preferences.insertRecentProjectFile(
@@ -2173,7 +2169,7 @@ const MainFrame = (props: Props) => {
// can happen if another project was loaded in the meantime.
setState(state => ({
...state,
currentFileMetadata: enrichedFileMetadata,
currentFileMetadata: fileMetadata,
}));
}
@@ -2889,6 +2885,8 @@ const MainFrame = (props: Props) => {
canInstallPrivateAsset,
onChooseProject: () => openOpenFromStorageProviderDialog(),
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
onOpenGamesDashboard: gameId =>
openGameDashboard({ gameId, tab: 'details' }),
onOpenNewProjectSetupDialog: exampleShortHeader => {
setSelectedExampleShortHeader(exampleShortHeader);
setNewProjectSetupDialogOpen(true);

View File

@@ -59,15 +59,30 @@ const zipProjectAndCommitVersion = async ({
export const generateOnSaveProject = (
authenticatedUser: AuthenticatedUser
) => async (project: gdProject, fileMetadata: FileMetadata) => {
if (!fileMetadata.gameId) {
console.info('Game id was never set, updating the cloud project.');
try {
await updateCloudProject(authenticatedUser, fileMetadata.fileIdentifier, {
gameId: project.getProjectUuid(),
});
} catch (error) {
console.error('Could not update cloud project with gameId', error);
// Do not throw, as this is not a blocking error.
}
}
const newFileMetadata = {
...fileMetadata,
gameId: project.getProjectUuid(),
};
const newVersion = await zipProjectAndCommitVersion({
authenticatedUser,
project,
cloudProjectId: fileMetadata.fileIdentifier,
cloudProjectId: newFileMetadata.fileIdentifier,
});
if (!newVersion) return { wasSaved: false, fileMetadata };
if (!newVersion) return { wasSaved: false, fileMetadata: newFileMetadata };
return {
wasSaved: true,
fileMetadata,
fileMetadata: newFileMetadata,
};
};
@@ -76,7 +91,7 @@ export const generateOnChangeProjectProperty = (
) => async (
project: gdProject,
fileMetadata: FileMetadata,
properties: { name: string }
properties: {| name?: string, gameId?: string |}
): Promise<boolean> => {
if (!authenticatedUser.authenticated) return false;
try {
@@ -180,7 +195,7 @@ export const generateOnSaveProjectAs = (
) => {
if (!saveAsLocation)
throw new Error('A location was not chosen before saving as.');
const { name } = saveAsLocation;
const { name, gameId } = saveAsLocation;
if (!name) throw new Error('A name was not chosen before saving as.');
if (!authenticatedUser.authenticated) {
return { wasSaved: false, fileMetadata: null };
@@ -191,12 +206,14 @@ export const generateOnSaveProjectAs = (
// Create a new cloud project.
const cloudProject = await createCloudProject(authenticatedUser, {
name,
gameId,
});
if (!cloudProject)
throw new Error('No cloud project was returned from creation api call.');
const fileMetadata = {
fileIdentifier: cloudProject.id,
gameId,
};
// Move the resources to the new project.

View File

@@ -130,8 +130,11 @@ export const onSaveProject = (
'Project file is empty, "Save as" should have been called?'
);
}
// Ensure we always pick the latest name and gameId.
const newFileMetadata = {
...fileMetadata,
name: project.getName(),
gameId: project.getProjectUuid(),
lastModifiedDate: now,
};
@@ -192,7 +195,13 @@ export const onSaveProjectAs = async (
throw new Error('A file path was not chosen before saving as.');
options.onStartSaving();
const newFileMetadata = { fileIdentifier: filePath };
// Ensure we always pick the latest name and gameId.
const newFileMetadata = {
fileIdentifier: filePath,
name: project.getName(),
gameId: project.getProjectUuid(),
lastModifiedDate: Date.now(),
};
// Move (copy or download, etc...) the resources first.
await options.onMoveResources({ newFileMetadata });

View File

@@ -13,6 +13,7 @@ export type FileMetadata = {|
fileIdentifier: string,
lastModifiedDate?: number,
name?: string,
gameId?: string,
|};
/**
@@ -30,6 +31,10 @@ export type SaveAsLocation = {|
* (for example, a local file path is stored only in `fileIdentifier`).
*/
name?: string,
/**
* The id of the game. Might be null if unused
*/
gameId?: string,
// New fields can be added if a storage provider needs other things to identify
// a new location where to save a project to.
@@ -97,7 +102,7 @@ export type StorageProviderOperations = {|
onChangeProjectProperty?: (
project: gdProject,
fileMetadata: FileMetadata,
properties: { name: string } // In order to synchronize project and cloud project names.
properties: {| name?: string, gameId?: string |} // In order to synchronize project and cloud project names.
) => Promise<boolean>,
// Project auto saving:

View File

@@ -40,6 +40,7 @@ export type UploadedProjectResourceFiles = Array<{|
type CloudProject = {|
id: string,
name: string,
gameId?: string,
createdAt: string,
currentVersion?: string,
deletedAt?: string,
@@ -109,7 +110,7 @@ export const clearCloudProjectCredentials = async (): Promise<void> => {
export const createCloudProject = async (
authenticatedUser: AuthenticatedUser,
cloudProjectCreationPayload: { name: string }
cloudProjectCreationPayload: {| name: string, gameId?: string |}
): Promise<?CloudProject> => {
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
if (!firebaseUser) return null;
@@ -251,13 +252,16 @@ export const getCloudProject = async (
export const updateCloudProject = async (
authenticatedUser: AuthenticatedUser,
cloudProjectId: string,
attributes: { name: string }
attributes: {| name?: string, gameId?: string |}
): Promise<?CloudProject> => {
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
if (!firebaseUser) return;
const cleanedAttributes = {
name: attributes.name.slice(0, CLOUD_PROJECT_NAME_MAX_LENGTH),
name: attributes.name
? attributes.name.slice(0, CLOUD_PROJECT_NAME_MAX_LENGTH)
: undefined,
gameId: attributes.gameId,
};
const { uid: userId } = firebaseUser;

View File

@@ -117,5 +117,6 @@ export const useOpenInitialDialog = ({ parameters, actions }: Props) => {
setGamesDashboardInitialGameId,
gamesDashboardInitialTab,
setGamesDashboardInitialTab,
openGameDashboard,
};
};

View File

@@ -119,6 +119,7 @@ const WrappedHomePage = ({
storageProviders={[CloudStorageProvider]}
onChooseProject={() => action('onChooseProject')()}
onOpenRecentFile={() => action('onOpenRecentFile')()}
onOpenGamesDashboard={() => action('onOpenGamesDashboard')()}
onCreateProject={() => action('onCreateProject')()}
onOpenProjectManager={() => action('onOpenProjectManager')()}
onOpenHelpFinder={() => action('onOpenHelpFinder')()}