From 297dc43286336621a2935635cd60e1dc0a358f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Vivet?= Date: Fri, 24 Jul 2026 22:23:51 +0200 Subject: [PATCH] Show a placeholder for broken or missing images in the shop Add an ImageWithFallback component that displays a 'Missing Content' placeholder (with a broken-image icon) when an image fails to load or has no source, keeping the same layout to avoid layout shift. Use it in the shop tiles and the media gallery in place of CorsAwareImage. --- newIDE/app/src/AssetStore/ShopTiles.js | 15 +-- .../app/src/UI/CustomSvgIcons/BrokenImage.js | 11 +++ newIDE/app/src/UI/ImageWithFallback.js | 99 +++++++++++++++++++ newIDE/app/src/UI/ResponsiveMediaGallery.js | 8 +- 4 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 newIDE/app/src/UI/CustomSvgIcons/BrokenImage.js create mode 100644 newIDE/app/src/UI/ImageWithFallback.js 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' ? ( -