diff --git a/newIDE/app/src/AssetStore/ShopTiles.js b/newIDE/app/src/AssetStore/ShopTiles.js index 79c8f0d01b1c..906202b5c5ae 100644 --- a/newIDE/app/src/AssetStore/ShopTiles.js +++ b/newIDE/app/src/AssetStore/ShopTiles.js @@ -15,6 +15,7 @@ import { import type { ExampleShortHeader } from '../Utils/GDevelopServices/Example'; import GridListTile from '@material-ui/core/GridListTile'; import { CorsAwareImage } from '../UI/CorsAwareImage'; +import { ImageWithFallback } from '../UI/ImageWithFallback'; import { textEllipsisStyle } from '../UI/TextEllipsis'; import { Column, Line, Spacer } from '../UI/Grid'; import Text from '../UI/Text'; @@ -208,7 +209,7 @@ export const PublicAssetPackTile = ({ id={`asset-pack-${assetPack.tag.replace(/\s/g, '-')}`} noOverflowParent > -
-
- - - - {exampleShortHeader ? ( thumbnailImgUrl ? ( - ( + + + +)); diff --git a/newIDE/app/src/UI/ImageWithFallback.js b/newIDE/app/src/UI/ImageWithFallback.js new file mode 100644 index 000000000000..5f90647df0cd --- /dev/null +++ b/newIDE/app/src/UI/ImageWithFallback.js @@ -0,0 +1,99 @@ +// @flow +import * as React from 'react'; +import { Trans } from '@lingui/macro'; +import { CorsAwareImage } from './CorsAwareImage'; +import BrokenImage from './CustomSvgIcons/BrokenImage'; +import Text from './Text'; +import GDevelopThemeContext from './Theme/GDevelopThemeContext'; + +type Props = {| + src: ?string, + alt: ?string, + style?: Object, + loading?: 'lazy', +|}; + +const styles = { + fallbackContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + borderRadius: 8, + border: '1px solid', + boxSizing: 'border-box', // Take border in account for sizing to avoid cumulative layout shift. + }, + fallbackIcon: { + width: '25%', + height: '25%', + opacity: 0.5, + }, +}; + +/** + * An image that displays a placeholder icon when the source fails to load + * (broken/missing image), while keeping the same layout (size, border, ratio) + * to avoid any layout shift. + */ +export const ImageWithFallback = ({ + src, + alt, + style, + loading, + ...props +}: Props): React.MixedElement => { + const gdevelopTheme = React.useContext(GDevelopThemeContext); + const [hasError, setHasError] = React.useState(false); + const [isLoaded, setIsLoaded] = React.useState(false); + + // Reset the loading/error state if the source changes (e.g. when navigating + // through a carousel, or when a tile is reused in a list). + React.useEffect( + () => { + setHasError(false); + setIsLoaded(false); + }, + [src] + ); + + if (hasError || !src) { + return ( +
+ + + Missing Content + +
+ ); + } + + return ( + setIsLoaded(true)} + onError={() => setHasError(true)} + /> + ); +}; diff --git a/newIDE/app/src/UI/ResponsiveMediaGallery.js b/newIDE/app/src/UI/ResponsiveMediaGallery.js index a4bfc01a6f0d..25c7785e4b5d 100644 --- a/newIDE/app/src/UI/ResponsiveMediaGallery.js +++ b/newIDE/app/src/UI/ResponsiveMediaGallery.js @@ -4,7 +4,7 @@ import Measure from 'react-measure'; import { makeStyles } from '@material-ui/core/styles'; import CardMedia from '@material-ui/core/CardMedia'; import Grid from '@material-ui/core/Grid'; -import { CorsAwareImage } from './CorsAwareImage'; +import { ImageWithFallback } from './ImageWithFallback'; import { Line } from './Grid'; import { shouldValidate } from './KeyboardShortcuts/InteractionKeys'; import { useResponsiveWindowSize } from './Responsive/ResponsiveWindowMeasurer'; @@ -192,7 +192,7 @@ const ResponsiveMediaGallery = ({ > {kind === 'image' ? ( - {selectedMedia.kind === 'image' ? ( - {kind === 'image' ? ( -