Compare commits

..

6 Commits

Author SHA1 Message Date
Davy Hélard
e93e65e7e1 Review changes: alert and parallelism 2022-04-12 14:25:41 +02:00
Davy Hélard
b67e7035bc Rewording of the tip. 2022-04-12 14:25:41 +02:00
Davy Hélard
b0f824f889 Disable slugs fields and add a tip when the user doesn't have a username. 2022-04-12 14:25:41 +02:00
Davy Hélard
ee7065d52c Allow to verify and change the url slug right before publishing a game 2022-04-12 14:22:52 +02:00
Fannie Yan
13204e4b53 Add a button "Access public profile" in one's profile details (#3819)
Add a button in profile details that opens the user's public profile on Liluo.io
2022-04-11 14:41:08 +02:00
D8H
c6d6466d54 Fix the input checkboxes "Playable with [...]" randomly checked when publishing a game for the first time (#3818) 2022-04-08 11:45:20 +01:00
7 changed files with 212 additions and 111 deletions

View File

@@ -66,6 +66,9 @@ Project::Project()
projectUuid(""),
useDeprecatedZeroAsDefaultZOrder(false),
useExternalSourceFiles(false),
isPlayableWithKeyboard(false),
isPlayableWithGamepad(false),
isPlayableWithMobile(false),
currentPlatform(NULL),
gdMajorVersion(gd::VersionWrapper::Major()),
gdMinorVersion(gd::VersionWrapper::Minor()),

View File

@@ -38,16 +38,16 @@ import {
getGame,
getGameUrl,
updateGame,
setGameSlug,
getGameSlugs,
type Game,
setGameUserAcls,
getAclsFromUserIds,
type GameSlug,
} from '../../../Utils/GDevelopServices/Game';
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
import AlertMessage from '../../../UI/AlertMessage';
import OnlineGamePropertiesDialog from './OnlineGamePropertiesDialog';
import { showErrorBox } from '../../../UI/Messages/MessageBox';
import { type PartialGameChange } from '../../../GameDashboard/PublicGamePropertiesDialog';
import { type Profile } from '../../../Utils/GDevelopServices/Authentication';
const styles = {
icon: {
@@ -55,26 +55,6 @@ const styles = {
},
};
const tryUpdateAuthors = async (
getAuthorizationHeader: () => Promise<string>,
project: gdProject,
profile: Profile
) => {
const authorAcls = getAclsFromUserIds(project.getAuthorIds().toJSArray());
try {
await setGameUserAcls(
getAuthorizationHeader,
profile.id,
project.getProjectUuid(),
{ author: authorAcls }
);
} catch (e) {
// Best effort call, do not prevent exporting the game.
console.error(e);
}
};
type OnlineGameLinkProps = {|
build: ?Build,
project: gdProject,
@@ -101,6 +81,7 @@ const OnlineGameLink = ({
setIsOnlineGamePropertiesDialogOpen,
] = React.useState<boolean>(false);
const [game, setGame] = React.useState<?Game>(null);
const [slug, setSlug] = React.useState<?GameSlug>(null);
const [isGameLoading, setIsGameLoading] = React.useState<boolean>(false);
const { getAuthorizationHeader, profile } = React.useContext(
AuthenticatedUserContext
@@ -109,7 +90,7 @@ const OnlineGameLink = ({
const exportPending = !errored && exportStep !== '' && exportStep !== 'done';
const isBuildComplete = build && build.status === 'complete';
const isBuildPublished = build && game && build.id === game.publicWebBuildId;
const gameUrl = getGameUrl(game);
const gameUrl = getGameUrl(game, slug);
const buildUrl =
exportPending || !isBuildComplete
? null
@@ -125,43 +106,23 @@ const OnlineGameLink = ({
const { id } = profile;
try {
setIsGameLoading(true);
const game = await getGame(getAuthorizationHeader, id, gameId);
let updatedGame = null;
try {
const isPublishedForTheFirstTime =
build && game.publicWebBuildId === build.id;
if (isPublishedForTheFirstTime) {
updatedGame = await updateGame(getAuthorizationHeader, id, gameId, {
authorName: project.getAuthor() || 'Unspecified publisher',
gameName: project.getName() || 'Untitled game',
description: project.getDescription(),
playWithGamepad: project.isPlayableWithGamepad(),
playWithKeyboard: project.isPlayableWithKeyboard(),
playWithMobile: project.isPlayableWithMobile(),
orientation: project.getOrientation(),
categories: project.getCategories().toJSArray(),
thumbnailUrl: build
? getWebBuildThumbnailUrl(project, build.id)
: undefined,
discoverable: false,
});
// We don't await for the authors update, as it is not required for publishing.
if (profile) {
tryUpdateAuthors(getAuthorizationHeader, project, profile);
}
}
} catch (err) {
console.error('Unable to update the game', err);
const [game, slugs] = await Promise.all([
getGame(getAuthorizationHeader, id, gameId),
getGameSlugs(getAuthorizationHeader, id, gameId).catch(err => {
console.error('Unable to get the game slug', err);
}),
]);
setGame(game);
if (slugs && slugs.length > 0) {
setSlug(slugs[0]);
}
setGame(updatedGame || game);
} catch (err) {
console.error('Unable to load the game', err);
} finally {
setIsGameLoading(false);
}
},
[build, getAuthorizationHeader, profile, project]
[build, getAuthorizationHeader, profile]
);
React.useEffect(
@@ -240,6 +201,35 @@ const OnlineGameLink = ({
}
);
setGame(updatedGame);
const { userSlug, gameSlug } = partialGameChange;
if (userSlug && gameSlug && userSlug === profile.username) {
try {
await setGameSlug(
getAuthorizationHeader,
id,
game.id,
userSlug,
gameSlug
);
setSlug({ username: userSlug, gameSlug: gameSlug, createdAt: 0 });
} catch (error) {
console.error(
'Unable to update the game slug:',
error.response || error.message
);
showErrorBox({
message:
i18n._(
t`Unable to update the game slug. A slug must be 6 to 30 characters long and only contains letters, digits or dashes.`
) +
' ' +
i18n._(t`Verify your internet connection or try again later.`),
rawError: error,
errorId: 'game-slug-update-error',
});
return false;
}
}
} catch (err) {
showErrorBox({
message: i18n._(
@@ -428,6 +418,7 @@ const OnlineGameLink = ({
}
}}
game={game}
slug={slug}
isLoading={isGameLoading}
/>
)}

View File

@@ -2,22 +2,27 @@
import { t, Trans } from '@lingui/macro';
import * as React from 'react';
import { type Game } from '../../../Utils/GDevelopServices/Game';
import { type Game, type GameSlug } from '../../../Utils/GDevelopServices/Game';
import FlatButton from '../../../UI/FlatButton';
import Dialog from '../../../UI/Dialog';
import PublicGameProperties from '../../../GameDashboard/PublicGameProperties';
import {
cleanUpGameSlug,
PublicGameProperties,
} from '../../../GameDashboard/PublicGameProperties';
import {
applyPublicPropertiesToProject,
type PartialGameChange,
} from '../../../GameDashboard/PublicGamePropertiesDialog';
import { getWebBuildThumbnailUrl } from '../../../Utils/GDevelopServices/Build';
import RaisedButtonWithSplitMenu from '../../../UI/RaisedButtonWithSplitMenu';
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
type Props = {|
project: gdProject,
onSaveProject: () => Promise<void>,
buildId: string,
game: Game,
slug: ?GameSlug,
onClose: () => void,
onApply: PartialGameChange => Promise<void>,
isLoading: boolean,
@@ -28,10 +33,13 @@ export const OnlineGamePropertiesDialog = ({
onSaveProject,
buildId,
game,
slug,
onClose,
onApply,
isLoading,
}: Props) => {
const { profile } = React.useContext(AuthenticatedUserContext);
const [name, setName] = React.useState<string>(project.getName());
const [categories, setCategories] = React.useState<string[]>(
project.getCategories().toJSArray()
@@ -51,6 +59,12 @@ export const OnlineGamePropertiesDialog = ({
const [playWithMobile, setPlayableWithMobile] = React.useState<boolean>(
project.isPlayableWithMobile()
);
const [userSlug, setUserSlug] = React.useState<string>(
(slug && slug.username) || (profile && profile.username) || ''
);
const [gameSlug, setGameSlug] = React.useState<string>(
(slug && slug.gameSlug) || cleanUpGameSlug(project.getName())
);
const [orientation, setOrientation] = React.useState<string>(
project.getOrientation()
);
@@ -78,7 +92,7 @@ export const OnlineGamePropertiesDialog = ({
orientation: orientation || 'default',
})
) {
await onApply({ discoverable });
await onApply({ discoverable, userSlug, gameSlug });
}
};
@@ -131,6 +145,10 @@ export const OnlineGamePropertiesDialog = ({
playWithMobile={playWithMobile}
setOrientation={setOrientation}
orientation={orientation}
userSlug={userSlug}
setUserSlug={setUserSlug}
gameSlug={gameSlug}
setGameSlug={setGameSlug}
discoverable={discoverable}
setDiscoverable={setDiscoverable}
displayThumbnail

View File

@@ -17,6 +17,7 @@ import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { I18n } from '@lingui/react';
import { Column, Line, Spacer } from '../UI/Grid';
import BackgroundText from '../UI/BackgroundText';
import AlertMessage from '../UI/AlertMessage';
import { GameThumbnail } from './GameThumbnail';
const isCyrillic = (text: string) =>
@@ -195,42 +196,68 @@ export function PublicGameProperties({
rows={5}
/>
{setUserSlug && setGameSlug && (
<Line>
<SelectField
fullWidth
floatingLabelText={<Trans>User name in the game URL</Trans>}
value={userSlug || ''}
onChange={(e, i, value: string) => setUserSlug(value)}
// It's disabled if one of the condition of SelectOption is false.
disabled={
!(
profile &&
profile.username &&
userSlug &&
(!profile || userSlug !== profile.username)
)
}
>
{profile && profile.username && (
<SelectOption
value={profile.username}
primaryText={profile.username}
/>
)}
{userSlug && (!profile || userSlug !== profile.username) && (
<SelectOption value={userSlug} primaryText={userSlug} />
)}
</SelectField>
<Spacer />
<SemiControlledTextField
floatingLabelText={<Trans>Game name in the game URL</Trans>}
fullWidth
type="text"
value={gameSlug || ''}
onChange={gameSlug => setGameSlug(cleanUpGameSlug(gameSlug))}
autoFocus
/>
</Line>
<>
<Line>
<SelectField
fullWidth
floatingLabelText={<Trans>User name in the game URL</Trans>}
value={userSlug || ''}
onChange={(e, i, value: string) => setUserSlug(value)}
// It's disabled if one of the condition of SelectOption is false.
disabled={
!(
profile &&
profile.username &&
userSlug &&
(!profile || userSlug !== profile.username)
)
}
>
{profile && profile.username && (
<SelectOption
value={profile.username}
primaryText={profile.username}
/>
)}
{userSlug && (!profile || userSlug !== profile.username) && (
<SelectOption value={userSlug} primaryText={userSlug} />
)}
</SelectField>
<Spacer />
<SemiControlledTextField
disabled={
!(
userSlug &&
userSlug.length &&
profile &&
profile.username
)
}
floatingLabelText={<Trans>Game name in the game URL</Trans>}
fullWidth
type="text"
value={
userSlug && userSlug.length && profile && profile.username
? gameSlug || ''
: ''
}
onChange={gameSlug => setGameSlug(cleanUpGameSlug(gameSlug))}
autoFocus
/>
</Line>
{!(
userSlug &&
userSlug.length &&
profile &&
profile.username
) && (
<AlertMessage kind="info">
<Trans>
Usernames are required to choose a custom game URL.
</Trans>
</AlertMessage>
)}
</>
)}
<UsersAutocomplete
userIds={authorIds}

View File

@@ -3,6 +3,7 @@ import { Trans, t } from '@lingui/macro';
import * as React from 'react';
import Avatar from '@material-ui/core/Avatar';
import OpenInNew from '@material-ui/icons/OpenInNew';
import { Column, Line, Spacer } from '../UI/Grid';
import { ResponsiveLineStackLayout } from '../UI/Layout';
import PlaceholderLoader from '../UI/PlaceholderLoader';
@@ -14,8 +15,11 @@ import PlaceholderError from '../UI/PlaceholderError';
import RaisedButton from '../UI/RaisedButton';
import UserAchievements from './Achievement/UserAchievements';
import { type Badge } from '../Utils/GDevelopServices/Badge';
import Window from '../Utils/Window';
import { GDevelopGamesPlatform } from '../Utils/GDevelopServices/ApiConfigs';
type DisplayedProfile = {
id: string,
+email?: string,
description: ?string,
username: ?string,
@@ -44,21 +48,41 @@ const ProfileDetails = ({
<I18n>
{({ i18n }) => (
<Column>
<Line alignItems="center">
<Avatar src={getGravatarUrl(profile.email || '', { size: 40 })} />
<Spacer />
<Text
size="title"
style={{
opacity: profile.username ? 1.0 : 0.5,
}}
>
{profile.username ||
(isAuthenticatedUserProfile
? i18n._(t`Edit your profile to pick a username!`)
: i18n._(t`No username`))}
</Text>
</Line>
<ResponsiveLineStackLayout
alignItems="center"
justifyContent="space-between"
noColumnMargin
>
<Line>
<Avatar src={getGravatarUrl(profile.email || '', { size: 40 })} />
<Spacer />
<Text
size="title"
style={{
opacity: profile.username ? 1.0 : 0.5,
}}
>
{profile.username ||
(isAuthenticatedUserProfile
? i18n._(t`Edit your profile to pick a username!`)
: i18n._(t`No username`))}
</Text>
</Line>
{profile.id && (
<RaisedButton
label={i18n._(t`Access public profile`)}
onClick={() =>
Window.openExternalURL(
GDevelopGamesPlatform.getUserPublicProfileUrl(
profile.id,
profile.username
)
)
}
icon={<OpenInNew />}
/>
)}
</ResponsiveLineStackLayout>
{isAuthenticatedUserProfile && profile.email && (
<Line>
<TextField

View File

@@ -1,5 +1,7 @@
// @flow
const isDev = process.env.NODE_ENV === 'development';
import Window from '../Window';
const isDev = Window.isDev();
export const GDevelopGamePreviews = {
baseUrl: `https://game-previews.gdevelop-app.com/`,
@@ -14,6 +16,14 @@ export const GDevelopGamesPlatform = {
isDev
? `https://liluo.io/games/${gameId}?dev=true`
: `https://liluo.io/games/${gameId}`,
getGameUrlWithSlug: (userSlug: string, gameSlug: string) =>
isDev
? `https://liluo.io/${userSlug.toLowerCase()}/${gameSlug.toLowerCase()}?dev=true`
: `https://liluo.io/${userSlug.toLowerCase()}/${gameSlug.toLowerCase()}`,
getUserPublicProfileUrl: (userId: string, username: ?string) =>
username
? `https://liluo.io/${username}${isDev ? '?dev=true' : ''}`
: `https://liluo.io/user/${userId}${isDev ? '?dev=true' : ''}`,
};
export const GDevelopBuildApi = {

View File

@@ -38,6 +38,12 @@ export type Game = {
discoverable?: boolean,
};
export type GameSlug = {
username: string,
gameSlug: string,
createdAt: number,
};
export type ShowcasedGameLink = {
url: string,
type:
@@ -129,9 +135,11 @@ export const getCategoryName = (category: string, i18n: I18nType) => {
}
};
export const getGameUrl = (game: ?Game) => {
export const getGameUrl = (game: ?Game, slug: ?GameSlug) => {
if (!game) return null;
return GDevelopGamesPlatform.getGameUrl(game.id);
return slug
? GDevelopGamesPlatform.getGameUrlWithSlug(slug.username, slug.gameSlug)
: GDevelopGamesPlatform.getGameUrl(game.id);
};
export const getAclsFromUserIds = (
@@ -374,3 +382,23 @@ export const getPublicGame = (gameId: string): Promise<PublicGame> => {
.get(`${GDevelopGameApi.baseUrl}/public-game/${gameId}`)
.then(response => response.data);
};
export const getGameSlugs = (
getAuthorizationHeader: () => Promise<string>,
userId: string,
gameId: string
): Promise<Array<GameSlug>> => {
return getAuthorizationHeader()
.then(authorizationHeader =>
axios.get(`${GDevelopGameApi.baseUrl}/game-slug`, {
params: {
userId,
gameId,
},
headers: {
Authorization: authorizationHeader,
},
})
)
.then(response => response.data);
};