From 1f7f472527329f14b5962ca67146d0a84821f7df Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki Date: Fri, 22 Aug 2025 18:11:45 +0100 Subject: [PATCH 01/10] wip more galleries in progress Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com> --- .../src/components/Carousel.importable.tsx | 2 + .../src/components/MoreGalleries.stories.tsx | 187 ++++++++++++++++++ .../src/components/MoreGalleries.tsx | 29 +++ .../components/OnwardsUpper.importable.tsx | 3 + dotcom-rendering/src/lib/image.ts | 6 +- 5 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 dotcom-rendering/src/components/MoreGalleries.stories.tsx create mode 100644 dotcom-rendering/src/components/MoreGalleries.tsx diff --git a/dotcom-rendering/src/components/Carousel.importable.tsx b/dotcom-rendering/src/components/Carousel.importable.tsx index 021bafd31c3..b85d1cf34c7 100644 --- a/dotcom-rendering/src/components/Carousel.importable.tsx +++ b/dotcom-rendering/src/components/Carousel.importable.tsx @@ -880,6 +880,8 @@ export const Carousel = ({ return null; } + console.log('*************************** rendering related contents'); + return (
; + +export default meta; + +type Story = StoryObj; + +export const MoreGalleries = { + args: { + format: { + theme: Pillar.News, + design: ArticleDesign.Gallery, + display: ArticleDisplay.Standard, + }, + absoluteServerTimes: false, + discussionApiUrl: + 'https://discussion.code.dev-theguardian.com/discussion-api', + trails: [ + { + url: 'http://localhost:9000/environment/gallery/2025/aug/22/week-in-wildlife-a-clumsy-fox-swinging-orangutang-and-rescued-jaguarundi-cub', + linkText: + 'Week in wildlife: a clumsy fox, a swinging orangutan and a rescued jaguarundi cub', + showByline: false, + byline: 'Pejman Faratin', + image: { + src: 'https://media.guim.co.uk/a81e974ffee6c8c88fa280c2d02eaf5dc2af863e/151_292_1020_816/master/1020.jpg', + altText: '', + }, + format: { + theme: Pillar.News, + design: ArticleDesign.Gallery, + display: ArticleDisplay.Standard, + }, + webPublicationDate: '2025-08-22T06:00:25.000Z', + headline: + 'Week in wildlife: a clumsy fox, a swinging orangutan and a rescued jaguarundi cub', + shortUrl: 'https://www.theguardian.com/p/x32n89', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x32n89', + }, + dataLinkName: getDataLinkNameCard( + { + theme: Pillar.News, + design: ArticleDesign.Gallery, + display: ArticleDisplay.Standard, + }, + '0', + 0, + ), + }, + { + url: 'http://localhost:9000/money/gallery/2025/aug/22/characterful-cottages-for-sale-in-england-in-pictures', + linkText: + 'Characterful cottages for sale in England – in pictures', + showByline: false, + byline: 'Anna White', + masterImage: + 'https://media.guim.co.uk/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg', + image: 'https://i.guim.co.uk/img/media/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg?width=300&quality=85&auto=format&fit=max&s=35fb647440143717424928ec795146e7', + carouselImages: { + 300: 'https://i.guim.co.uk/img/media/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg?width=300&quality=85&auto=format&fit=max&s=35fb647440143717424928ec795146e7', + 460: 'https://i.guim.co.uk/img/media/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg?width=460&quality=85&auto=format&fit=max&s=372ea9d012b1d9d96d0ab0a74aa9f4cf', + }, + isLiveBlog: false, + pillar: 'lifestyle', + designType: 'Media', + format: { + design: 'GalleryDesign', + theme: 'LifestylePillar', + display: 'StandardDisplay', + }, + webPublicationDate: '2025-08-22T06:00:24.000Z', + headline: + 'Characterful cottages for sale in England – in pictures', + mediaType: 'Gallery', + shortUrl: 'https://www.theguardian.com/p/x32gqj', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x32gqj', + }, + }, + { + url: 'http://localhost:9000/news/gallery/2025/aug/22/sunsets-aid-parachutes-and-giant-pandas-photos-of-the-day-friday', + linkText: + 'Sunsets, aid parachutes and giant pandas: photos of the day – Friday ', + showByline: false, + byline: 'Eithne Staunton', + masterImage: + 'https://media.guim.co.uk/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png', + image: 'https://i.guim.co.uk/img/media/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png?width=300&quality=85&auto=format&fit=max&s=0fe5f2cd010943be553fef6725d4b617', + carouselImages: { + 300: 'https://i.guim.co.uk/img/media/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png?width=300&quality=85&auto=format&fit=max&s=0fe5f2cd010943be553fef6725d4b617', + 460: 'https://i.guim.co.uk/img/media/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png?width=460&quality=85&auto=format&fit=max&s=966e5c2a61465aa333a1e3efa4851bfc', + }, + isLiveBlog: false, + pillar: 'news', + designType: 'Media', + format: { + design: 'GalleryDesign', + theme: 'NewsPillar', + display: 'StandardDisplay', + }, + webPublicationDate: '2025-08-22T12:49:42.000Z', + headline: + 'Sunsets, aid parachutes and giant pandas: photos of the day – Friday ', + mediaType: 'Gallery', + shortUrl: 'https://www.theguardian.com/p/x3359z', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x3359z', + }, + }, + { + url: 'http://localhost:9000/fashion/gallery/2025/aug/22/what-to-wear-to-notting-hill-carnival', + linkText: 'On parade: what to wear to Notting Hill carnival', + showByline: false, + byline: 'Melanie Wilkinson', + masterImage: + 'https://media.guim.co.uk/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg', + image: 'https://i.guim.co.uk/img/media/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg?width=300&quality=85&auto=format&fit=max&s=552a5c40f2dbe4ec5e41eb7ce38cc51b', + carouselImages: { + 300: 'https://i.guim.co.uk/img/media/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg?width=300&quality=85&auto=format&fit=max&s=552a5c40f2dbe4ec5e41eb7ce38cc51b', + 460: 'https://i.guim.co.uk/img/media/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg?width=460&quality=85&auto=format&fit=max&s=92465638ffbcf894d49bdcb033b9ddfd', + }, + isLiveBlog: false, + pillar: 'lifestyle', + designType: 'Media', + format: { + design: 'GalleryDesign', + theme: 'LifestylePillar', + display: 'StandardDisplay', + }, + webPublicationDate: '2025-08-22T05:00:23.000Z', + headline: 'On parade: what to wear to Notting Hill carnival', + mediaType: 'Gallery', + shortUrl: 'https://www.theguardian.com/p/x32mte', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x32mte', + }, + }, + { + url: 'http://localhost:9000/artanddesign/gallery/2025/aug/21/psychedelic-rock-glass-mountain-michael-lundgren', + linkText: + 'Psychedelic rock! Formations that mess with your mind – in pictures ', + showByline: false, + masterImage: + 'https://media.guim.co.uk/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg', + image: 'https://i.guim.co.uk/img/media/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg?width=300&quality=85&auto=format&fit=max&s=3105a2cb22c9aa1c4247cb1d373c449d', + carouselImages: { + 300: 'https://i.guim.co.uk/img/media/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg?width=300&quality=85&auto=format&fit=max&s=3105a2cb22c9aa1c4247cb1d373c449d', + 460: 'https://i.guim.co.uk/img/media/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg?width=460&quality=85&auto=format&fit=max&s=59f834675fd82a57db4d61ffe76ac759', + }, + isLiveBlog: false, + pillar: 'culture', + designType: 'Media', + format: { + design: 'GalleryDesign', + theme: 'CulturePillar', + display: 'StandardDisplay', + }, + webPublicationDate: '2025-08-21T06:01:01.000Z', + headline: + 'Psychedelic rock! Formations that mess with your mind – in pictures ', + mediaType: 'Gallery', + shortUrl: 'https://www.theguardian.com/p/x2p663', + discussion: { + isCommentable: false, + isClosedForComments: true, + discussionId: '/p/x2p663', + }, + }, + ], + }, +} satisfies Story; diff --git a/dotcom-rendering/src/components/MoreGalleries.tsx b/dotcom-rendering/src/components/MoreGalleries.tsx new file mode 100644 index 00000000000..e9b077379ed --- /dev/null +++ b/dotcom-rendering/src/components/MoreGalleries.tsx @@ -0,0 +1,29 @@ +import { type ArticleFormat } from '../lib/articleFormat'; +import { type TrailType } from '../types/trails'; +import { Card } from './Card/Card'; + +type Props = { + format: ArticleFormat; + absoluteServerTimes: boolean; + trails: TrailType[]; + discussionApiUrl: string; +}; + +export const MoreGalleries = (props: Props) => { + return ( +
+ {props.trails.map((trail) => ( + + ))} +
+ ); +}; diff --git a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx index 66f8751cfe6..9add4fc61b8 100644 --- a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx +++ b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx @@ -303,6 +303,9 @@ export const OnwardsUpper = ({ ? getContainerDataUrl(pillar, editionId, ajaxUrl) : undefined; + console.log(`url: ${url}`); + console.log(`curatedDataUrl: ${curatedDataUrl}`); + return (
{!!url && ( diff --git a/dotcom-rendering/src/lib/image.ts b/dotcom-rendering/src/lib/image.ts index 83df6577a9a..b1cecfb21b3 100644 --- a/dotcom-rendering/src/lib/image.ts +++ b/dotcom-rendering/src/lib/image.ts @@ -59,6 +59,7 @@ export const generateImageURL = ({ aspectRatio?: string; cropOffset?: { x: number; y: number }; }): string => { + console.log(`mainImage: ${mainImage}`); const url = new URL(mainImage); const offset = cropOffset ? `,offset-x${cropOffset.x},offset-y${cropOffset.y}` @@ -74,9 +75,12 @@ export const generateImageURL = ({ const domain = isCodeGridUrl(url) ? 'i.guimcode.co.uk' : 'i.guim.co.uk'; - return `https://${domain}/img/${getServiceFromUrl(url)}${ + const res = `https://${domain}/img/${getServiceFromUrl(url)}${ url.pathname }?${params.toString()}`; + + console.log(`res url: ${res}`); + return res; }; export const isSupported = (imageUrl: string): boolean => { From af5b06b7866addd41d15704ac1c7ddd90993ff1c Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki Date: Wed, 3 Sep 2025 14:18:00 +0100 Subject: [PATCH 02/10] add more galleries container --- dotcom-rendering/src/components/Card/Card.tsx | 14 +- .../components/Card/components/TrailText.tsx | 20 +- .../FetchOnwardsData.importable.tsx | 48 +-- .../src/components/MoreGalleries.stories.tsx | 138 +++++---- .../src/components/MoreGalleries.tsx | 273 +++++++++++++++++- .../src/layouts/GalleryLayout.tsx | 15 + dotcom-rendering/src/lib/decideTrail.ts | 29 ++ dotcom-rendering/src/paletteDeclarations.ts | 12 + dotcom-rendering/src/types/onwards.ts | 2 + dotcom-rendering/src/types/trails.ts | 2 + 10 files changed, 449 insertions(+), 104 deletions(-) diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 73a28276350..8735af4b7f3 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -30,7 +30,7 @@ import type { DCRSupportingContent, } from '../../types/front'; import type { MainMedia } from '../../types/mainMedia'; -import type { OnwardsSource } from '../../types/onwards'; +import type { OnwardContainerType, OnwardsSource } from '../../types/onwards'; import { Avatar } from '../Avatar'; import { CardCommentCount } from '../CardCommentCount.importable'; import { CardHeadline, type ResponsiveFontSize } from '../CardHeadline'; @@ -120,7 +120,7 @@ export type Props = { supportingContentPosition?: Position; snapData?: DCRSnapType; containerPalette?: DCRContainerPalette; - containerType?: DCRContainerType; + containerType?: DCRContainerType | OnwardContainerType; showAge?: boolean; discussionApiUrl: string; discussionId?: string; @@ -574,6 +574,8 @@ export const Card = ({ containerType === 'flexible/special' || containerType === 'flexible/general'; + const isOnwardContainer = containerType === 'more-galleries'; + const isSmallCard = containerType === 'scrollable/small'; const imageFixedSizeOptions = (): ImageFixedSizeOptions => { @@ -591,6 +593,8 @@ export const Card = ({ const hideTrailTextUntil = () => { if (isFlexibleContainer) { return undefined; + } else if (isOnwardContainer && isFlexSplash) { + return 'mobile'; } else if ( imageSize === 'large' && imagePositionOnDesktop === 'right' && @@ -602,6 +606,10 @@ export const Card = ({ } }; + const shouldShowTrailText = isOnwardContainer + ? media?.type !== 'podcast' && isFlexSplash + : media?.type !== 'podcast'; + /** * Determines the gap of between card components based on card properties * Order matters here as the logic is based on the card properties @@ -1090,7 +1098,7 @@ export const Card = ({ )} - {!!trailText && media?.type !== 'podcast' && ( + {!!trailText && shouldShowTrailText && ( { + return css` + display: flex; + flex-direction: column; - ${until.tablet} { - display: none; - } -`; + ${hideUntil === 'mobile' ? until.mobile : until.tablet} { + display: none; + } + `; +}; const bottomPadding = css` padding-bottom: ${space[2]}px; @@ -44,7 +46,7 @@ type Props = { /** Optionally overrides the trail text colour */ trailTextColour?: string; /** Controls visibility of trail text on various breakpoints */ - hideUntil?: 'tablet' | 'desktop'; + hideUntil?: 'tablet' | 'desktop' | 'mobile'; /** Defaults to `true`. Adds padding to the bottom of the trail text */ padBottom?: boolean; /** Adds padding to the top of the trail text */ @@ -62,7 +64,7 @@ export const TrailText = ({ const trailText = (
{ return trails .filter((trailType) => !(isTrailPaidContent(trailType) && isAdFreeUser)) .slice(0, trailLimit) - .map(decideTrail); + .map(withMasterImage ? decideTrailWithMasterImage : decideTrail); }; export const FetchOnwardsData = ({ @@ -83,22 +85,32 @@ export const FetchOnwardsData = ({ return (
- + {onwardsSource === 'more-galleries' ? ( + + ) : ( + + )}
); }; diff --git a/dotcom-rendering/src/components/MoreGalleries.stories.tsx b/dotcom-rendering/src/components/MoreGalleries.stories.tsx index 157acf6153e..4ca6afa0232 100644 --- a/dotcom-rendering/src/components/MoreGalleries.stories.tsx +++ b/dotcom-rendering/src/components/MoreGalleries.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { getDataLinkNameCard } from '../lib/getDataLinkName'; import { MoreGalleries as MoreGalleriesComponent } from './MoreGalleries'; -import { getDataLinkNameCard } from 'src/lib/getDataLinkName'; const meta = { title: 'Components/MoreGalleries', @@ -14,14 +14,12 @@ type Story = StoryObj; export const MoreGalleries = { args: { - format: { - theme: Pillar.News, - design: ArticleDesign.Gallery, - display: ArticleDisplay.Standard, - }, absoluteServerTimes: false, discussionApiUrl: 'https://discussion.code.dev-theguardian.com/discussion-api', + heading: 'More galleries', + url: 'http://localhost:9000/more-galleries', + onwardsSource: 'more-galleries', trails: [ { url: 'http://localhost:9000/environment/gallery/2025/aug/22/week-in-wildlife-a-clumsy-fox-swinging-orangutang-and-rescued-jaguarundi-cub', @@ -56,6 +54,10 @@ export const MoreGalleries = { '0', 0, ), + trailText: + 'Guinness World Records is looking back at the extraordinary feats achieved since its inception - as well as unveiling 70 whacky and unclaimed records ', + kickerText: 'Politics', // Get data for this + mainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for this }, { url: 'http://localhost:9000/money/gallery/2025/aug/22/characterful-cottages-for-sale-in-england-in-pictures', @@ -63,31 +65,36 @@ export const MoreGalleries = { 'Characterful cottages for sale in England – in pictures', showByline: false, byline: 'Anna White', - masterImage: - 'https://media.guim.co.uk/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg', - image: 'https://i.guim.co.uk/img/media/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg?width=300&quality=85&auto=format&fit=max&s=35fb647440143717424928ec795146e7', - carouselImages: { - 300: 'https://i.guim.co.uk/img/media/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg?width=300&quality=85&auto=format&fit=max&s=35fb647440143717424928ec795146e7', - 460: 'https://i.guim.co.uk/img/media/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg?width=460&quality=85&auto=format&fit=max&s=372ea9d012b1d9d96d0ab0a74aa9f4cf', + image: { + src: 'https://media.guim.co.uk/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg', + altText: '', }, - isLiveBlog: false, - pillar: 'lifestyle', - designType: 'Media', format: { - design: 'GalleryDesign', - theme: 'LifestylePillar', - display: 'StandardDisplay', + design: ArticleDesign.Gallery, + theme: Pillar.Lifestyle, + display: ArticleDisplay.Standard, }, webPublicationDate: '2025-08-22T06:00:24.000Z', headline: 'Characterful cottages for sale in England – in pictures', - mediaType: 'Gallery', shortUrl: 'https://www.theguardian.com/p/x32gqj', discussion: { isCommentable: false, isClosedForComments: true, discussionId: '/p/x32gqj', }, + dataLinkName: getDataLinkNameCard( + { + design: ArticleDesign.Gallery, + theme: Pillar.Lifestyle, + display: ArticleDisplay.Standard, + }, + '0', + 1, + ), + trailText: + 'Picked from a record 60,636 entries, the first images from the Natural History Museum’s wildlife photographer of the year competition have been released. The photographs, which range from a lion facing down a cobra to magnified mould spores, show the diversity, beauty and complexity of the natural world and humanity’s relationship with it', + mainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for this }, { url: 'http://localhost:9000/news/gallery/2025/aug/22/sunsets-aid-parachutes-and-giant-pandas-photos-of-the-day-friday', @@ -95,92 +102,107 @@ export const MoreGalleries = { 'Sunsets, aid parachutes and giant pandas: photos of the day – Friday ', showByline: false, byline: 'Eithne Staunton', - masterImage: - 'https://media.guim.co.uk/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png', - image: 'https://i.guim.co.uk/img/media/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png?width=300&quality=85&auto=format&fit=max&s=0fe5f2cd010943be553fef6725d4b617', - carouselImages: { - 300: 'https://i.guim.co.uk/img/media/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png?width=300&quality=85&auto=format&fit=max&s=0fe5f2cd010943be553fef6725d4b617', - 460: 'https://i.guim.co.uk/img/media/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png?width=460&quality=85&auto=format&fit=max&s=966e5c2a61465aa333a1e3efa4851bfc', + image: { + src: 'https://media.guim.co.uk/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png', + altText: '', }, - isLiveBlog: false, - pillar: 'news', - designType: 'Media', format: { - design: 'GalleryDesign', - theme: 'NewsPillar', - display: 'StandardDisplay', + design: ArticleDesign.Gallery, + theme: Pillar.News, + display: ArticleDisplay.Standard, }, webPublicationDate: '2025-08-22T12:49:42.000Z', headline: 'Sunsets, aid parachutes and giant pandas: photos of the day – Friday ', - mediaType: 'Gallery', shortUrl: 'https://www.theguardian.com/p/x3359z', discussion: { isCommentable: false, isClosedForComments: true, discussionId: '/p/x3359z', }, + dataLinkName: getDataLinkNameCard( + { + design: ArticleDesign.Gallery, + theme: Pillar.News, + display: ArticleDisplay.Standard, + }, + '0', + 2, + ), + trailText: + 'From the mock-Tudor fad of the 1920s to drivers refuelling on a roundabout, each era produces its own distinctive petrol stations – as photographer Philip Butler discovered', + mainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for this }, { url: 'http://localhost:9000/fashion/gallery/2025/aug/22/what-to-wear-to-notting-hill-carnival', linkText: 'On parade: what to wear to Notting Hill carnival', showByline: false, byline: 'Melanie Wilkinson', - masterImage: - 'https://media.guim.co.uk/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg', - image: 'https://i.guim.co.uk/img/media/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg?width=300&quality=85&auto=format&fit=max&s=552a5c40f2dbe4ec5e41eb7ce38cc51b', - carouselImages: { - 300: 'https://i.guim.co.uk/img/media/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg?width=300&quality=85&auto=format&fit=max&s=552a5c40f2dbe4ec5e41eb7ce38cc51b', - 460: 'https://i.guim.co.uk/img/media/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg?width=460&quality=85&auto=format&fit=max&s=92465638ffbcf894d49bdcb033b9ddfd', + image: { + src: 'https://media.guim.co.uk/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg', + altText: '', }, - isLiveBlog: false, - pillar: 'lifestyle', - designType: 'Media', format: { - design: 'GalleryDesign', - theme: 'LifestylePillar', - display: 'StandardDisplay', + design: ArticleDesign.Gallery, + theme: Pillar.Lifestyle, + display: ArticleDisplay.Standard, }, webPublicationDate: '2025-08-22T05:00:23.000Z', headline: 'On parade: what to wear to Notting Hill carnival', - mediaType: 'Gallery', shortUrl: 'https://www.theguardian.com/p/x32mte', discussion: { isCommentable: false, isClosedForComments: true, discussionId: '/p/x32mte', }, + dataLinkName: getDataLinkNameCard( + { + design: ArticleDesign.Gallery, + theme: Pillar.Lifestyle, + display: ArticleDisplay.Standard, + }, + '0', + 1, + ), + trailText: + 'The Guardian’s picture editors select photographs from around the world', + mainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for thismainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for this }, { url: 'http://localhost:9000/artanddesign/gallery/2025/aug/21/psychedelic-rock-glass-mountain-michael-lundgren', linkText: 'Psychedelic rock! Formations that mess with your mind – in pictures ', showByline: false, - masterImage: - 'https://media.guim.co.uk/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg', - image: 'https://i.guim.co.uk/img/media/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg?width=300&quality=85&auto=format&fit=max&s=3105a2cb22c9aa1c4247cb1d373c449d', - carouselImages: { - 300: 'https://i.guim.co.uk/img/media/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg?width=300&quality=85&auto=format&fit=max&s=3105a2cb22c9aa1c4247cb1d373c449d', - 460: 'https://i.guim.co.uk/img/media/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg?width=460&quality=85&auto=format&fit=max&s=59f834675fd82a57db4d61ffe76ac759', + image: { + src: 'https://media.guim.co.uk/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg', + altText: '', }, - isLiveBlog: false, - pillar: 'culture', - designType: 'Media', format: { - design: 'GalleryDesign', - theme: 'CulturePillar', - display: 'StandardDisplay', + design: ArticleDesign.Gallery, + theme: Pillar.Culture, + display: ArticleDisplay.Standard, }, webPublicationDate: '2025-08-21T06:01:01.000Z', headline: 'Psychedelic rock! Formations that mess with your mind – in pictures ', - mediaType: 'Gallery', shortUrl: 'https://www.theguardian.com/p/x2p663', discussion: { isCommentable: false, isClosedForComments: true, discussionId: '/p/x2p663', }, + dataLinkName: getDataLinkNameCard( + { + design: ArticleDesign.Gallery, + theme: Pillar.Culture, + display: ArticleDisplay.Standard, + }, + '0', + 1, + ), + trailText: + 'Politicians and their partners put on their best show at this year’s Midwinter Ball, an annual dinner hosted by the Federal Parliamentary Press Gallery in Canberra', + mainMedia: { type: 'Gallery', count: '6' }, // TODO: get data for this }, ], }, diff --git a/dotcom-rendering/src/components/MoreGalleries.tsx b/dotcom-rendering/src/components/MoreGalleries.tsx index e9b077379ed..897e96c402a 100644 --- a/dotcom-rendering/src/components/MoreGalleries.tsx +++ b/dotcom-rendering/src/components/MoreGalleries.tsx @@ -1,29 +1,270 @@ -import { type ArticleFormat } from '../lib/articleFormat'; +import { css } from '@emotion/react'; +import { + from, + headlineBold24, + headlineBold28, + space, + until, +} from '@guardian/source/foundations'; +import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import { formatAttrString } from '../lib/formatAttrString'; +import { palette as themePalette } from '../palette'; +import { type OnwardsSource } from '../types/onwards'; import { type TrailType } from '../types/trails'; import { Card } from './Card/Card'; +import type { Props as CardProps } from './Card/Card'; +import { Hide } from './Hide'; +import { LeftColumn } from './LeftColumn'; +import { Section } from './Section'; type Props = { - format: ArticleFormat; absoluteServerTimes: boolean; trails: TrailType[]; discussionApiUrl: string; + heading: string; + onwardsSource: OnwardsSource; + url?: string; +}; + +const wrapperStyle = css` + display: flex; + justify-content: space-between; + overflow: hidden; + ${from.desktop} { + padding-right: 40px; + } +`; + +const containerStyles = css` + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; /* Needed for scrolling to work */ + + margin-top: ${space[2]}px; + padding-bottom: ${space[6]}px; + + margin-left: 0px; + margin-right: 0px; + + border-bottom: 1px solid ${themePalette('--onward-content-border')}; + + ${from.leftCol} { + margin-left: 10px; + margin-right: 100px; + } +`; + +const standardCardStyles = css` + flex: 1; + + position: relative; + display: flex; + padding: ${space[2]}px; + background-color: ${themePalette('--onward-card-background')}; + + :not(:first-child)::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -10px; /* shift into the gap */ + width: 1px; + background: ${themePalette('--onward-content-border')}; + } +`; + +const standardCardsListStyles = css` + width: 100%; + display: flex; + flex-direction: row; + gap: 20px; + + ${from.tablet} { + padding-top: ${space[2]}px; + } + + ${until.tablet} { + flex-direction: column; + width: 100%; + } +`; + +const headerStyles = css` + color: ${themePalette('--carousel-text')}; + ${headlineBold24}; + padding-bottom: ${space[3]}px; + padding-top: ${space[1]}px; + margin-left: 0; + + ${from.tablet} { + ${headlineBold28}; + } +`; + +const headerStylesWithUrl = css` + :hover { + text-decoration: underline; + } +`; + +const titleStyle = css` + color: ${themePalette('--onward-text')}; + display: inline-block; + &::first-letter { + text-transform: capitalize; + } +`; + +const getDefaultCardProps = ( + trail: TrailType, + absoluteServerTimes: boolean, + discussionApiUrl: string, +) => { + const defaultProps: CardProps = { + linkTo: trail.url, + format: trail.format, + headlineText: trail.headline, + byline: trail.byline, + showByline: trail.showByline, + showQuotedHeadline: trail.showQuotedHeadline, + webPublicationDate: trail.webPublicationDate, + kickerText: trail.kickerText, + showPulsingDot: false, + showClock: false, + image: trail.image, + isCrossword: trail.isCrossword, + starRating: trail.starRating, + dataLinkName: trail.dataLinkName, + snapData: trail.snapData, + discussionApiUrl, + discussionId: trail.discussionId, + avatarUrl: trail.avatarUrl, + mainMedia: trail.mainMedia, + isExternalLink: false, + branding: trail.branding, + absoluteServerTimes, + imageLoading: 'lazy', + trailText: trail.trailText, + showAge: false, // TODO + containerType: 'more-galleries', + showTopBarDesktop: false, + showTopBarMobile: false, + aspectRatio: '5:4', + }; + return defaultProps; }; export const MoreGalleries = (props: Props) => { + const [firstTrail, ...standardCards] = props.trails; + if (!firstTrail) return null; + + const defaultProps = getDefaultCardProps( + firstTrail, + props.absoluteServerTimes, + props.discussionApiUrl, + ); + return ( -
- {props.trails.map((trail) => ( - - ))} -
+
+
+ + + </LeftColumn> + + <div + css={containerStyles} + data-component={props.onwardsSource} + data-link={formatAttrString(props.heading)} + > + <Hide when="above" breakpoint="leftCol"> + <Title title={props.heading} url={props.url} /> + </Hide> + + <MoreGalleriesSplashCard defaultProps={defaultProps} /> + <Hide when="below" breakpoint="tablet"> + <StraightLines + count={1} + color={themePalette('--onward-content-border')} + /> + </Hide> + + <ul css={standardCardsListStyles}> + {standardCards.map((trail) => ( + <li key={trail.url} css={standardCardStyles}> + {Card({ + ...getDefaultCardProps( + trail, + props.absoluteServerTimes, + props.discussionApiUrl, + ), + imageSize: 'medium', + })} + </li> + ))} + </ul> + </div> + </div> + </Section> ); }; + +const MoreGalleriesSplashCard = ({ + defaultProps, +}: { + defaultProps: CardProps; +}) => { + const cardProps: Partial<CardProps> = { + headlineSizes: { + desktop: 'medium', + tablet: 'medium', + mobile: 'medium', + }, + imagePositionOnDesktop: 'right', + imagePositionOnMobile: 'top', + imageSize: 'large', + isFlexSplash: true, + }; + return ( + <div + css={css` + margin-bottom: ${space[6]}px; + background-color: ${themePalette('--onward-card-background')}; + padding: ${space[2]}px; + `} + > + {Card({ ...defaultProps, ...cardProps })} + </div> + ); +}; + +const Title = ({ title, url }: { title: string; url?: string }) => + url ? ( + <a + css={css` + text-decoration: none; + `} + href={url} + data-link-name="section heading" // TODO + > + <h2 css={headerStyles}> + <span css={[headerStylesWithUrl, titleStyle]}>{title}</span> + </h2> + </a> + ) : ( + <h2 css={headerStyles}> + <span css={titleStyle}>{title}</span> + </h2> + ); diff --git a/dotcom-rendering/src/layouts/GalleryLayout.tsx b/dotcom-rendering/src/layouts/GalleryLayout.tsx index 1fc81d18e94..31dc00cab65 100644 --- a/dotcom-rendering/src/layouts/GalleryLayout.tsx +++ b/dotcom-rendering/src/layouts/GalleryLayout.tsx @@ -18,6 +18,7 @@ import { ArticleTitle } from '../components/ArticleTitle'; import { Caption } from '../components/Caption'; import { Carousel } from '../components/Carousel.importable'; import { DiscussionLayout } from '../components/DiscussionLayout'; +import { FetchOnwardsData } from '../components/FetchOnwardsData.importable'; import { Footer } from '../components/Footer'; import { DesktopAdSlot, MobileAdSlot } from '../components/GalleryAdSlots'; import { GalleryImage } from '../components/GalleryImage'; @@ -379,6 +380,20 @@ export const GalleryLayout = (props: WebProps | AppProps) => { frontendData.showBottomSocialButtons && isWeb } /> + <Island priority="feature" defer={{ until: 'visible' }}> + <FetchOnwardsData + url={`${gallery.frontendData.config.ajaxUrl}/gallery/most-viewed.json?dcr=true`} // TODO: Fix the url for the app version too + limit={5} + onwardsSource={'more-galleries'} + format={format} + discussionApiUrl={discussionApiUrl} + absoluteServerTimes={ + switches['absoluteServerTimes'] ?? false + } + renderingTarget={renderingTarget} + isAdFreeUser={frontendData.isAdFreeUser} + /> + </Island> </main> {/* More galleries container */} {showMerchandisingHigh && ( diff --git a/dotcom-rendering/src/lib/decideTrail.ts b/dotcom-rendering/src/lib/decideTrail.ts index f92dccc75df..b301dfcfb13 100644 --- a/dotcom-rendering/src/lib/decideTrail.ts +++ b/dotcom-rendering/src/lib/decideTrail.ts @@ -1,4 +1,5 @@ import type { DCRFrontImage } from '../types/front'; +import { type MainMedia } from '../types/mainMedia'; import type { FETrailType, TrailType } from '../types/trails'; import { type ArticleFormat, decideFormat } from './articleFormat'; import { getDataLinkNameCard } from './getDataLinkName'; @@ -19,3 +20,31 @@ export const decideTrail = (trail: FETrailType, index = 0): TrailType => { dataLinkName: getDataLinkNameCard(format, '0', index), }; }; + +export const decideTrailWithMasterImage = ( + trail: FETrailType, + index = 0, +): TrailType => { + const format: ArticleFormat = decideFormat(trail.format); + const image: DCRFrontImage | undefined = trail.masterImage + ? { + src: trail.masterImage, + altText: '', // TODO: Do we get this from frontend? + } + : undefined; + + return { + ...trail, + image, + format, + dataLinkName: getDataLinkNameCard(format, '0', index), + mainMedia: getMedia(trail.galleryCount), + }; +}; + +const getMedia = (galleryCount: number | undefined): MainMedia | undefined => { + if (typeof galleryCount === 'number') { + return { type: 'Gallery', count: galleryCount.toString() }; + } + return undefined; +}; diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index 17c11820121..f4f9405f0a1 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -7361,10 +7361,22 @@ const paletteColours = { light: numberedListTitleLight, dark: numberedListTitleDark, }, + '--onward-background': { + light: () => sourcePalette.neutral[100], + dark: () => sourcePalette.neutral[0], + }, + '--onward-card-background': { + light: () => sourcePalette.neutral[97], + dark: () => sourcePalette.neutral[20], + }, '--onward-content-border': { light: onwardContentBorderLight, dark: () => sourcePalette.neutral[20], }, + '--onward-text': { + light: () => sourcePalette.neutral[7], + dark: () => sourcePalette.neutral[86], + }, '--pagination-text': { light: paginationTextLight, dark: paginationTextDark, diff --git a/dotcom-rendering/src/types/onwards.ts b/dotcom-rendering/src/types/onwards.ts index 948ca173272..5d0ee426cca 100644 --- a/dotcom-rendering/src/types/onwards.ts +++ b/dotcom-rendering/src/types/onwards.ts @@ -24,3 +24,5 @@ export type OnwardsSource = | 'curated-content' | 'newsletters-page' | 'unknown-source'; // We should never see this in the analytics data! + +export type OnwardContainerType = 'more-galleries'; diff --git a/dotcom-rendering/src/types/trails.ts b/dotcom-rendering/src/types/trails.ts index 396a31072c7..d773e90968b 100644 --- a/dotcom-rendering/src/types/trails.ts +++ b/dotcom-rendering/src/types/trails.ts @@ -30,6 +30,8 @@ interface BaseTrailType { discussionId?: string; }; mainMedia?: MainMedia; + trailText?: string; + galleryCount?: number; } export interface TrailType extends BaseTrailType { From ce90bda53aebb1753a89ddd28d926703d984d20b Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Fri, 5 Sep 2025 14:26:49 +0100 Subject: [PATCH 03/10] reduce image size for more gallery splash card --- dotcom-rendering/src/components/Card/Card.tsx | 4 +--- .../src/components/FetchOnwardsData.importable.tsx | 2 +- dotcom-rendering/src/components/MoreGalleries.tsx | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 8735af4b7f3..94224f345f0 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -591,10 +591,8 @@ export const Card = ({ }; const hideTrailTextUntil = () => { - if (isFlexibleContainer) { + if (isFlexibleContainer || (isOnwardContainer && isFlexSplash)) { return undefined; - } else if (isOnwardContainer && isFlexSplash) { - return 'mobile'; } else if ( imageSize === 'large' && imagePositionOnDesktop === 'right' && diff --git a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx index f2b55cd4383..54cdf7cfd71 100644 --- a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx +++ b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx @@ -90,7 +90,7 @@ export const FetchOnwardsData = ({ absoluteServerTimes={absoluteServerTimes} trails={buildTrails(data.trails, limit, isAdFreeUser, true)} discussionApiUrl={discussionApiUrl} - heading={'More Galleries'} + heading="More galleries" onwardsSource={onwardsSource} /> ) : ( diff --git a/dotcom-rendering/src/components/MoreGalleries.tsx b/dotcom-rendering/src/components/MoreGalleries.tsx index 897e96c402a..e2abb5fcf97 100644 --- a/dotcom-rendering/src/components/MoreGalleries.tsx +++ b/dotcom-rendering/src/components/MoreGalleries.tsx @@ -234,7 +234,7 @@ const MoreGalleriesSplashCard = ({ }, imagePositionOnDesktop: 'right', imagePositionOnMobile: 'top', - imageSize: 'large', + imageSize: 'medium', isFlexSplash: true, }; return ( From 383908acbcc43180cb05e29f267152e57b12fe2c Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Fri, 5 Sep 2025 15:12:05 +0100 Subject: [PATCH 04/10] add valibot parser to validate more gallery response --- .../src/components/Carousel.importable.tsx | 2 - .../FetchMoreGalleriesData.importable.tsx | 168 ++++++++++++++++++ .../FetchOnwardsData.importable.tsx | 48 ++--- .../components/OnwardsUpper.importable.tsx | 3 - .../src/layouts/GalleryLayout.tsx | 9 +- dotcom-rendering/src/lib/image.ts | 6 +- dotcom-rendering/src/types/trails.ts | 4 + .../src/types/valibotSchemas/branding.ts | 25 +++ .../src/types/valibotSchemas/content.ts | 11 ++ .../src/types/valibotSchemas/feFormat.ts | 48 +++++ .../src/types/valibotSchemas/front.ts | 7 + .../src/types/valibotSchemas/mainMedia.ts | 51 ++++++ .../src/types/valibotSchemas/tag.ts | 6 + .../src/types/valibotSchemas/trails.ts | 48 +++++ 14 files changed, 391 insertions(+), 45 deletions(-) create mode 100644 dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx create mode 100644 dotcom-rendering/src/types/valibotSchemas/branding.ts create mode 100644 dotcom-rendering/src/types/valibotSchemas/content.ts create mode 100644 dotcom-rendering/src/types/valibotSchemas/feFormat.ts create mode 100644 dotcom-rendering/src/types/valibotSchemas/front.ts create mode 100644 dotcom-rendering/src/types/valibotSchemas/mainMedia.ts create mode 100644 dotcom-rendering/src/types/valibotSchemas/tag.ts create mode 100644 dotcom-rendering/src/types/valibotSchemas/trails.ts diff --git a/dotcom-rendering/src/components/Carousel.importable.tsx b/dotcom-rendering/src/components/Carousel.importable.tsx index b85d1cf34c7..021bafd31c3 100644 --- a/dotcom-rendering/src/components/Carousel.importable.tsx +++ b/dotcom-rendering/src/components/Carousel.importable.tsx @@ -880,8 +880,6 @@ export const Carousel = ({ return null; } - console.log('*************************** rendering related contents'); - return ( <CarouselColours props={props}> <div diff --git a/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx new file mode 100644 index 00000000000..611ade2ba8d --- /dev/null +++ b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx @@ -0,0 +1,168 @@ +import { css } from '@emotion/react'; +import { isNonNullable } from '@guardian/libs'; +import { useEffect, useState } from 'react'; +import { array, object, type Output, safeParse, string } from 'valibot'; +import { decideFormat } from '../lib/articleFormat'; +import { getDataLinkNameCard } from '../lib/getDataLinkName'; +import { addDiscussionIds } from '../lib/useCommentCount'; +import { palette } from '../palette'; +import { type DCRFrontImage } from '../types/front'; +import { type MainMedia } from '../types/mainMedia'; +import type { OnwardsSource } from '../types/onwards'; +import type { FETrailType, TrailType } from '../types/trails'; +import { FETrailTypeSchema } from '../types/valibotSchemas/trails'; +import { MoreGalleries } from './MoreGalleries'; +import { Placeholder } from './Placeholder'; + +type Props = { + url: string; + limit: number; // Limit the number of items shown (the api often returns more) + onwardsSource: OnwardsSource; + discussionApiUrl: string; + absoluteServerTimes: boolean; + isAdFreeUser: boolean; +}; + +type MoreGalleriesResponse = Output<typeof MoreGalleriesResponseSchema>; + +const MoreGalleriesResponseSchema = object({ + trails: array(FETrailTypeSchema), + heading: string(), +}); + +const minHeight = css` + min-height: 300px; +`; + +const getMedia = (galleryCount: number | undefined): MainMedia | undefined => { + if (typeof galleryCount === 'number') { + return { type: 'Gallery', count: galleryCount.toString() }; + } + return undefined; +}; + +const buildTrails = ( + trails: FETrailType[], + trailLimit: number, + isAdFreeUser: boolean, +): TrailType[] => { + return trails + .filter( + (trailType) => + !( + trailType.branding?.brandingType?.name === 'paid-content' && + isAdFreeUser + ), + ) + .slice(0, trailLimit) + .map((trail, index) => { + const format = decideFormat(trail.format); + const image: DCRFrontImage | undefined = trail.masterImage + ? { + src: trail.masterImage, + altText: '', + } + : undefined; + + return { + ...trail, + image, + format, + dataLinkName: getDataLinkNameCard(format, '0', index), + mainMedia: getMedia(trail.galleryCount), + }; + }); +}; + +const delay = (delayInms: number) => { + return new Promise((resolve) => setTimeout(resolve, delayInms)); +}; + +const fetchJson = async (ajaxUrl: string): Promise<MoreGalleriesResponse> => { + await delay(2000); + const fetchResponse = await fetch(ajaxUrl); + if (!fetchResponse.ok) { + throw new Error(`HTTP error! status: ${fetchResponse.status}`); + } + const responseJson: unknown = await fetchResponse.json(); + const result = safeParse(MoreGalleriesResponseSchema, responseJson, { + abortEarly: true, // Avoid parsing the rest of the object after facing the first error + }); + if (result.success) { + return result.output; + } else { + const errorMessages = result.issues + .map( + (issue) => + `${issue.path?.map((p) => p.key).join('.') ?? 'root'}: ${ + issue.message + }`, + ) + .join('; '); + throw new Error( + `Failed to parse MoreGalleriesResponse: ${errorMessages}`, + ); + } +}; + +export const FetchMoreGalleriesData = ({ + url, + limit, + onwardsSource, + discussionApiUrl, + absoluteServerTimes, + isAdFreeUser, +}: Props) => { + const [data, setData] = useState<MoreGalleriesResponse | undefined>( + undefined, + ); + const [error, setError] = useState<Error | null>(null); + + useEffect(() => { + fetchJson(url) + .then((fetchedData) => { + setData(fetchedData); + setError(null); + }) + .catch((err) => { + setError( + err instanceof Error ? err : new Error('Unknown error'), + ); + setData(undefined); + }); + }, [url]); + + if (error) { + // Send the error to Sentry and then prevent the element from rendering + window.guardian.modules.sentry.reportError(error, 'more-galleries'); + return null; + } + + if (!data?.trails) { + return ( + <Placeholder + height={720} // best guess at typical height // TODO: this is different value for different breakpoints!!! + shouldShimmer={false} + backgroundColor={palette('--onward-background')} + /> + ); + } + + addDiscussionIds( + data.trails + .map((trail) => trail.discussion?.discussionId) + .filter(isNonNullable), + ); + + return ( + <div css={minHeight}> + <MoreGalleries + absoluteServerTimes={absoluteServerTimes} + trails={buildTrails(data.trails, limit, isAdFreeUser)} + discussionApiUrl={discussionApiUrl} + heading="More galleries" + onwardsSource={onwardsSource} + /> + </div> + ); +}; diff --git a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx index 54cdf7cfd71..3c97f3b51e6 100644 --- a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx +++ b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/react'; import { isNonNullable } from '@guardian/libs'; import { ArticleDesign, type ArticleFormat } from '../lib/articleFormat'; -import { decideTrail, decideTrailWithMasterImage } from '../lib/decideTrail'; +import { decideTrail } from '../lib/decideTrail'; import { useApi } from '../lib/useApi'; import { addDiscussionIds } from '../lib/useCommentCount'; import { palette } from '../palette'; @@ -9,7 +9,6 @@ import type { OnwardsSource } from '../types/onwards'; import type { RenderingTarget } from '../types/renderingTarget'; import type { FETrailType, TrailType } from '../types/trails'; import { Carousel } from './Carousel.importable'; -import { MoreGalleries } from './MoreGalleries'; import { Placeholder } from './Placeholder'; type Props = { @@ -41,12 +40,11 @@ const buildTrails = ( trails: FETrailType[], trailLimit: number, isAdFreeUser: boolean, - withMasterImage = false, ): TrailType[] => { return trails .filter((trailType) => !(isTrailPaidContent(trailType) && isAdFreeUser)) .slice(0, trailLimit) - .map(withMasterImage ? decideTrailWithMasterImage : decideTrail); + .map(decideTrail); }; export const FetchOnwardsData = ({ @@ -85,32 +83,22 @@ export const FetchOnwardsData = ({ return ( <div css={minHeight}> - {onwardsSource === 'more-galleries' ? ( - <MoreGalleries - absoluteServerTimes={absoluteServerTimes} - trails={buildTrails(data.trails, limit, isAdFreeUser, true)} - discussionApiUrl={discussionApiUrl} - heading="More galleries" - onwardsSource={onwardsSource} - /> - ) : ( - <Carousel - heading={data.heading || data.displayname} // Sometimes the api returns heading as 'displayName' - trails={buildTrails(data.trails, limit, isAdFreeUser)} - description={data.description} - onwardsSource={onwardsSource} - format={format} - leftColSize={ - format.design === ArticleDesign.LiveBlog || - format.design === ArticleDesign.DeadBlog - ? 'wide' - : 'compact' - } - discussionApiUrl={discussionApiUrl} - absoluteServerTimes={absoluteServerTimes} - renderingTarget={renderingTarget} - /> - )} + <Carousel + heading={data.heading || data.displayname} // Sometimes the api returns heading as 'displayName' + trails={buildTrails(data.trails, limit, isAdFreeUser)} + description={data.description} + onwardsSource={onwardsSource} + format={format} + leftColSize={ + format.design === ArticleDesign.LiveBlog || + format.design === ArticleDesign.DeadBlog + ? 'wide' + : 'compact' + } + discussionApiUrl={discussionApiUrl} + absoluteServerTimes={absoluteServerTimes} + renderingTarget={renderingTarget} + /> </div> ); }; diff --git a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx index 9add4fc61b8..66f8751cfe6 100644 --- a/dotcom-rendering/src/components/OnwardsUpper.importable.tsx +++ b/dotcom-rendering/src/components/OnwardsUpper.importable.tsx @@ -303,9 +303,6 @@ export const OnwardsUpper = ({ ? getContainerDataUrl(pillar, editionId, ajaxUrl) : undefined; - console.log(`url: ${url}`); - console.log(`curatedDataUrl: ${curatedDataUrl}`); - return ( <div css={onwardsWrapper}> {!!url && ( diff --git a/dotcom-rendering/src/layouts/GalleryLayout.tsx b/dotcom-rendering/src/layouts/GalleryLayout.tsx index 31dc00cab65..07a2581e2df 100644 --- a/dotcom-rendering/src/layouts/GalleryLayout.tsx +++ b/dotcom-rendering/src/layouts/GalleryLayout.tsx @@ -18,7 +18,7 @@ import { ArticleTitle } from '../components/ArticleTitle'; import { Caption } from '../components/Caption'; import { Carousel } from '../components/Carousel.importable'; import { DiscussionLayout } from '../components/DiscussionLayout'; -import { FetchOnwardsData } from '../components/FetchOnwardsData.importable'; +import { FetchMoreGalleriesData } from '../components/FetchMoreGalleriesData.importable'; import { Footer } from '../components/Footer'; import { DesktopAdSlot, MobileAdSlot } from '../components/GalleryAdSlots'; import { GalleryImage } from '../components/GalleryImage'; @@ -380,17 +380,16 @@ export const GalleryLayout = (props: WebProps | AppProps) => { frontendData.showBottomSocialButtons && isWeb } /> + {/* TODO: I think to reduce the layout shift, we shouldn't defer until visible */} <Island priority="feature" defer={{ until: 'visible' }}> - <FetchOnwardsData - url={`${gallery.frontendData.config.ajaxUrl}/gallery/most-viewed.json?dcr=true`} // TODO: Fix the url for the app version too + <FetchMoreGalleriesData + url={`${gallery.frontendData.config.ajaxUrl}/gallery/most-viewed.json?dcr=true`} limit={5} onwardsSource={'more-galleries'} - format={format} discussionApiUrl={discussionApiUrl} absoluteServerTimes={ switches['absoluteServerTimes'] ?? false } - renderingTarget={renderingTarget} isAdFreeUser={frontendData.isAdFreeUser} /> </Island> diff --git a/dotcom-rendering/src/lib/image.ts b/dotcom-rendering/src/lib/image.ts index b1cecfb21b3..83df6577a9a 100644 --- a/dotcom-rendering/src/lib/image.ts +++ b/dotcom-rendering/src/lib/image.ts @@ -59,7 +59,6 @@ export const generateImageURL = ({ aspectRatio?: string; cropOffset?: { x: number; y: number }; }): string => { - console.log(`mainImage: ${mainImage}`); const url = new URL(mainImage); const offset = cropOffset ? `,offset-x${cropOffset.x},offset-y${cropOffset.y}` @@ -75,12 +74,9 @@ export const generateImageURL = ({ const domain = isCodeGridUrl(url) ? 'i.guimcode.co.uk' : 'i.guim.co.uk'; - const res = `https://${domain}/img/${getServiceFromUrl(url)}${ + return `https://${domain}/img/${getServiceFromUrl(url)}${ url.pathname }?${params.toString()}`; - - console.log(`res url: ${res}`); - return res; }; export const isSupported = (imageUrl: string): boolean => { diff --git a/dotcom-rendering/src/types/trails.ts b/dotcom-rendering/src/types/trails.ts index d773e90968b..a92336e45a8 100644 --- a/dotcom-rendering/src/types/trails.ts +++ b/dotcom-rendering/src/types/trails.ts @@ -34,6 +34,8 @@ interface BaseTrailType { galleryCount?: number; } +// export type BaseTrailType = Output<typeof BaseTrailTypeSchema> // TODO + export interface TrailType extends BaseTrailType { palette?: never; format: ArticleFormat; @@ -65,6 +67,8 @@ export interface FETrailType extends BaseTrailType { image?: string; } +// export type FETrailType = Output<typeof FETrailTypeSchema>; // TODO + export interface TrailTabType { heading: string; trails: TrailType[]; diff --git a/dotcom-rendering/src/types/valibotSchemas/branding.ts b/dotcom-rendering/src/types/valibotSchemas/branding.ts new file mode 100644 index 00000000000..8bca34a7172 --- /dev/null +++ b/dotcom-rendering/src/types/valibotSchemas/branding.ts @@ -0,0 +1,25 @@ +import { literal, number, object, optional, string, union } from 'valibot'; + +export const BrandingLogoSchema = object({ + src: string(), + link: string(), + label: string(), + dimensions: object({ + width: number(), + height: number(), + }), +}); + +export const BrandingTypeSchema = union([ + object({ name: literal('paid-content') }), + object({ name: literal('foundation') }), + object({ name: literal('sponsored') }), +]); + +export const BrandingSchema = object({ + brandingType: optional(BrandingTypeSchema), + sponsorName: string(), + logo: BrandingLogoSchema, + aboutThisLink: string(), + logoForDarkBackground: optional(BrandingLogoSchema), +}); diff --git a/dotcom-rendering/src/types/valibotSchemas/content.ts b/dotcom-rendering/src/types/valibotSchemas/content.ts new file mode 100644 index 00000000000..f9551a948cc --- /dev/null +++ b/dotcom-rendering/src/types/valibotSchemas/content.ts @@ -0,0 +1,11 @@ +import { literal, union } from 'valibot'; + +// StarRating is 0 | 1 | 2 | 3 | 4 | 5 +export const StarRatingSchema = union([ + literal(0), + literal(1), + literal(2), + literal(3), + literal(4), + literal(5), +]); diff --git a/dotcom-rendering/src/types/valibotSchemas/feFormat.ts b/dotcom-rendering/src/types/valibotSchemas/feFormat.ts new file mode 100644 index 00000000000..179a2fa0985 --- /dev/null +++ b/dotcom-rendering/src/types/valibotSchemas/feFormat.ts @@ -0,0 +1,48 @@ +import { literal, object, union } from 'valibot'; + +export const FEFormatSchema = object({ + design: union([ + literal('ArticleDesign'), + literal('PictureDesign'), + literal('GalleryDesign'), + literal('AudioDesign'), + literal('VideoDesign'), + literal('CrosswordDesign'), + literal('ReviewDesign'), + literal('AnalysisDesign'), + literal('CommentDesign'), + literal('ExplainerDesign'), + literal('LetterDesign'), + literal('FeatureDesign'), + literal('LiveBlogDesign'), + literal('DeadBlogDesign'), + literal('RecipeDesign'), + literal('MatchReportDesign'), + literal('InterviewDesign'), + literal('EditorialDesign'), + literal('QuizDesign'), + literal('InteractiveDesign'), + literal('PhotoEssayDesign'), + literal('ObituaryDesign'), + literal('FullPageInteractiveDesign'), + literal('NewsletterSignupDesign'), + literal('TimelineDesign'), + literal('ProfileDesign'), + ]), + theme: union([ + literal('NewsPillar'), + literal('OpinionPillar'), + literal('SportPillar'), + literal('CulturePillar'), + literal('LifestylePillar'), + literal('SpecialReportTheme'), + literal('Labs'), + literal('SpecialReportAltTheme'), + ]), + display: union([ + literal('StandardDisplay'), + literal('ImmersiveDisplay'), + literal('ShowcaseDisplay'), + literal('NumberedListDisplay'), + ]), +}); diff --git a/dotcom-rendering/src/types/valibotSchemas/front.ts b/dotcom-rendering/src/types/valibotSchemas/front.ts new file mode 100644 index 00000000000..b5943fb531d --- /dev/null +++ b/dotcom-rendering/src/types/valibotSchemas/front.ts @@ -0,0 +1,7 @@ +import { object, optional, string } from 'valibot'; + +export const DCRSnapTypeSchema = object({ + embedHtml: optional(string()), + embedCss: optional(string()), + embedJs: optional(string()), +}); diff --git a/dotcom-rendering/src/types/valibotSchemas/mainMedia.ts b/dotcom-rendering/src/types/valibotSchemas/mainMedia.ts new file mode 100644 index 00000000000..fa808766b0a --- /dev/null +++ b/dotcom-rendering/src/types/valibotSchemas/mainMedia.ts @@ -0,0 +1,51 @@ +import { + boolean, + literal, + number, + object, + optional, + string, + union, +} from 'valibot'; +import { PodcastSeriesImageSchema } from './tag'; + +const VideoSchema = object({ + type: literal('Video'), + id: string(), + videoId: string(), + height: number(), + width: number(), + origin: string(), + title: string(), + duration: number(), + expired: boolean(), + image: optional(string()), +}); + +const LoopVideoSchema = object({ + type: literal('LoopVideo'), + atomId: string(), + videoId: string(), + height: number(), + width: number(), + duration: number(), + image: optional(string()), +}); + +const AudioSchema = object({ + type: literal('Audio'), + duration: string(), + podcastImage: optional(PodcastSeriesImageSchema), +}); + +const GallerySchema = object({ + type: literal('Gallery'), + count: string(), +}); + +export const MainMediaSchema = union([ + VideoSchema, + LoopVideoSchema, + AudioSchema, + GallerySchema, +]); diff --git a/dotcom-rendering/src/types/valibotSchemas/tag.ts b/dotcom-rendering/src/types/valibotSchemas/tag.ts new file mode 100644 index 00000000000..62bf7939611 --- /dev/null +++ b/dotcom-rendering/src/types/valibotSchemas/tag.ts @@ -0,0 +1,6 @@ +import { object, optional, string } from 'valibot'; + +export const PodcastSeriesImageSchema = object({ + type: optional(string()), + altText: optional(string()), +}); diff --git a/dotcom-rendering/src/types/valibotSchemas/trails.ts b/dotcom-rendering/src/types/valibotSchemas/trails.ts new file mode 100644 index 00000000000..7cc34965890 --- /dev/null +++ b/dotcom-rendering/src/types/valibotSchemas/trails.ts @@ -0,0 +1,48 @@ +import { boolean, number, object, optional, record, string } from 'valibot'; +import { BrandingSchema } from './branding'; +import { StarRatingSchema } from './content'; +import { FEFormatSchema } from './feFormat'; +import { DCRSnapTypeSchema } from './front'; +import { MainMediaSchema } from './mainMedia'; + +export const DiscussionSchema = object({ + isCommentable: boolean(), + isClosedForComments: boolean(), + discussionId: optional(string()), +}); + +export const BaseTrailTypeSchema = object({ + url: string(), + headline: string(), + webPublicationDate: optional(string()), + avatarUrl: optional(string()), + mediaDuration: optional(number()), + ageWarning: optional(string()), + byline: optional(string()), + showByline: optional(boolean()), + kickerText: optional(string()), + shortUrl: optional(string()), + commentCount: optional(number()), + starRating: optional(StarRatingSchema), + linkText: optional(string()), + branding: optional(BrandingSchema), + isSnap: optional(boolean()), + isCrossword: optional(boolean()), + snapData: optional(DCRSnapTypeSchema), + showQuotedHeadline: optional(boolean()), + discussion: optional(DiscussionSchema), + mainMedia: optional(MainMediaSchema), + trailText: optional(string()), + galleryCount: optional(number()), +}); + +export const FETrailTypeSchema = object({ + ...BaseTrailTypeSchema.entries, + format: FEFormatSchema, + designType: optional(string()), + pillar: optional(string()), + carouselImages: optional(record(string(), string())), + isLiveBlog: optional(boolean()), + masterImage: optional(string()), + image: optional(string()), +}); From a9921c56a84e3f96afd8b00eed640522857f1dc0 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Fri, 5 Sep 2025 15:25:57 +0100 Subject: [PATCH 05/10] generated new schemas for feArticle and feFront --- dotcom-rendering/src/frontend/schemas/feArticle.json | 6 ++++++ dotcom-rendering/src/frontend/schemas/feFront.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 1212995104e..074637b0a84 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -5026,6 +5026,12 @@ }, "mainMedia": { "$ref": "#/definitions/MainMedia" + }, + "trailText": { + "type": "string" + }, + "galleryCount": { + "type": "number" } }, "required": [ diff --git a/dotcom-rendering/src/frontend/schemas/feFront.json b/dotcom-rendering/src/frontend/schemas/feFront.json index edd86811078..04eb2f0fd98 100644 --- a/dotcom-rendering/src/frontend/schemas/feFront.json +++ b/dotcom-rendering/src/frontend/schemas/feFront.json @@ -3792,6 +3792,12 @@ }, "mainMedia": { "$ref": "#/definitions/MainMedia" + }, + "trailText": { + "type": "string" + }, + "galleryCount": { + "type": "number" } }, "required": [ From 896ae3bc9cde57a705c4f73b8b9cca9efb7b0ce4 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Fri, 5 Sep 2025 15:26:49 +0100 Subject: [PATCH 06/10] clean up --- .../src/components/FetchMoreGalleriesData.importable.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx index 611ade2ba8d..b30c0fd2092 100644 --- a/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx +++ b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx @@ -74,12 +74,7 @@ const buildTrails = ( }); }; -const delay = (delayInms: number) => { - return new Promise((resolve) => setTimeout(resolve, delayInms)); -}; - const fetchJson = async (ajaxUrl: string): Promise<MoreGalleriesResponse> => { - await delay(2000); const fetchResponse = await fetch(ajaxUrl); if (!fetchResponse.ok) { throw new Error(`HTTP error! status: ${fetchResponse.status}`); From 71f3d2a3d253a9ea13e1c974b4a15ff4b4b72f5e Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Fri, 5 Sep 2025 16:36:01 +0100 Subject: [PATCH 07/10] clean up Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com> --- .../FetchMoreGalleriesData.importable.tsx | 44 ++++++++++--------- dotcom-rendering/src/lib/decideTrail.ts | 29 ------------ 2 files changed, 23 insertions(+), 50 deletions(-) diff --git a/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx index b30c0fd2092..dc512ff8123 100644 --- a/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx +++ b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx @@ -34,13 +34,31 @@ const minHeight = css` min-height: 300px; `; -const getMedia = (galleryCount: number | undefined): MainMedia | undefined => { +const getMedia = (galleryCount?: number): MainMedia | undefined => { if (typeof galleryCount === 'number') { return { type: 'Gallery', count: galleryCount.toString() }; } return undefined; }; +const toGalleryTrail = (trail: FETrailType, index: number): TrailType => { + const format = decideFormat(trail.format); + const image: DCRFrontImage | undefined = trail.masterImage + ? { + src: trail.masterImage, + altText: '', + } + : undefined; + + return { + ...trail, + image, + format, + dataLinkName: getDataLinkNameCard(format, '0', index), + mainMedia: getMedia(trail.galleryCount), + }; +}; + const buildTrails = ( trails: FETrailType[], trailLimit: number, @@ -55,23 +73,7 @@ const buildTrails = ( ), ) .slice(0, trailLimit) - .map((trail, index) => { - const format = decideFormat(trail.format); - const image: DCRFrontImage | undefined = trail.masterImage - ? { - src: trail.masterImage, - altText: '', - } - : undefined; - - return { - ...trail, - image, - format, - dataLinkName: getDataLinkNameCard(format, '0', index), - mainMedia: getMedia(trail.galleryCount), - }; - }); + .map(toGalleryTrail); }; const fetchJson = async (ajaxUrl: string): Promise<MoreGalleriesResponse> => { @@ -111,13 +113,13 @@ export const FetchMoreGalleriesData = ({ const [data, setData] = useState<MoreGalleriesResponse | undefined>( undefined, ); - const [error, setError] = useState<Error | null>(null); + const [error, setError] = useState<Error | undefined>(undefined); useEffect(() => { fetchJson(url) .then((fetchedData) => { setData(fetchedData); - setError(null); + setError(undefined); }) .catch((err) => { setError( @@ -130,7 +132,7 @@ export const FetchMoreGalleriesData = ({ if (error) { // Send the error to Sentry and then prevent the element from rendering window.guardian.modules.sentry.reportError(error, 'more-galleries'); - return null; + return undefined; } if (!data?.trails) { diff --git a/dotcom-rendering/src/lib/decideTrail.ts b/dotcom-rendering/src/lib/decideTrail.ts index b301dfcfb13..f92dccc75df 100644 --- a/dotcom-rendering/src/lib/decideTrail.ts +++ b/dotcom-rendering/src/lib/decideTrail.ts @@ -1,5 +1,4 @@ import type { DCRFrontImage } from '../types/front'; -import { type MainMedia } from '../types/mainMedia'; import type { FETrailType, TrailType } from '../types/trails'; import { type ArticleFormat, decideFormat } from './articleFormat'; import { getDataLinkNameCard } from './getDataLinkName'; @@ -20,31 +19,3 @@ export const decideTrail = (trail: FETrailType, index = 0): TrailType => { dataLinkName: getDataLinkNameCard(format, '0', index), }; }; - -export const decideTrailWithMasterImage = ( - trail: FETrailType, - index = 0, -): TrailType => { - const format: ArticleFormat = decideFormat(trail.format); - const image: DCRFrontImage | undefined = trail.masterImage - ? { - src: trail.masterImage, - altText: '', // TODO: Do we get this from frontend? - } - : undefined; - - return { - ...trail, - image, - format, - dataLinkName: getDataLinkNameCard(format, '0', index), - mainMedia: getMedia(trail.galleryCount), - }; -}; - -const getMedia = (galleryCount: number | undefined): MainMedia | undefined => { - if (typeof galleryCount === 'number') { - return { type: 'Gallery', count: galleryCount.toString() }; - } - return undefined; -}; From e4c1d01a83b64c83a66229e213c78689fea87ec5 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Fri, 5 Sep 2025 17:22:54 +0100 Subject: [PATCH 08/10] Inline valibot schemas alongside types Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com> --- .../FetchMoreGalleriesData.importable.tsx | 2 +- dotcom-rendering/src/frontend/feArticle.ts | 98 +++++++++-------- dotcom-rendering/src/types/branding.ts | 55 ++++++--- dotcom-rendering/src/types/content.ts | 12 +- dotcom-rendering/src/types/front.ts | 13 ++- dotcom-rendering/src/types/mainMedia.ts | 86 ++++++++------- dotcom-rendering/src/types/tag.ts | 12 +- dotcom-rendering/src/types/trails.ts | 104 ++++++++++-------- .../src/types/valibotSchemas/branding.ts | 25 ----- .../src/types/valibotSchemas/content.ts | 11 -- .../src/types/valibotSchemas/feFormat.ts | 48 -------- .../src/types/valibotSchemas/front.ts | 7 -- .../src/types/valibotSchemas/mainMedia.ts | 51 --------- .../src/types/valibotSchemas/tag.ts | 6 - .../src/types/valibotSchemas/trails.ts | 48 -------- 15 files changed, 227 insertions(+), 351 deletions(-) delete mode 100644 dotcom-rendering/src/types/valibotSchemas/branding.ts delete mode 100644 dotcom-rendering/src/types/valibotSchemas/content.ts delete mode 100644 dotcom-rendering/src/types/valibotSchemas/feFormat.ts delete mode 100644 dotcom-rendering/src/types/valibotSchemas/front.ts delete mode 100644 dotcom-rendering/src/types/valibotSchemas/mainMedia.ts delete mode 100644 dotcom-rendering/src/types/valibotSchemas/tag.ts delete mode 100644 dotcom-rendering/src/types/valibotSchemas/trails.ts diff --git a/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx index dc512ff8123..edbde64c395 100644 --- a/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx +++ b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx @@ -10,7 +10,7 @@ import { type DCRFrontImage } from '../types/front'; import { type MainMedia } from '../types/mainMedia'; import type { OnwardsSource } from '../types/onwards'; import type { FETrailType, TrailType } from '../types/trails'; -import { FETrailTypeSchema } from '../types/valibotSchemas/trails'; +import { FETrailTypeSchema } from '../types/trails'; import { MoreGalleries } from './MoreGalleries'; import { Placeholder } from './Placeholder'; diff --git a/dotcom-rendering/src/frontend/feArticle.ts b/dotcom-rendering/src/frontend/feArticle.ts index 31737764096..94790182154 100644 --- a/dotcom-rendering/src/frontend/feArticle.ts +++ b/dotcom-rendering/src/frontend/feArticle.ts @@ -1,4 +1,5 @@ import { type CrosswordProps } from '@guardian/react-crossword'; +import { literal, object, type Output, union } from 'valibot'; import type { EditionId } from '../lib/edition'; import type { FEArticleBadgeType } from '../types/badge'; import type { Block } from '../types/blocks'; @@ -135,65 +136,70 @@ type PageType = { isSensitive: boolean; }; -type ThemePillar = - | 'NewsPillar' - | 'OpinionPillar' - | 'SportPillar' - | 'CulturePillar' - | 'LifestylePillar'; - -type ThemeSpecial = 'SpecialReportTheme' | 'Labs' | 'SpecialReportAltTheme'; -type FETheme = ThemePillar | ThemeSpecial; +const FEThemeSchema = union([ + literal('NewsPillar'), + literal('OpinionPillar'), + literal('SportPillar'), + literal('CulturePillar'), + literal('LifestylePillar'), + literal('SpecialReportTheme'), + literal('Labs'), + literal('SpecialReportAltTheme'), +]); /** * FEDesign is what frontend gives (originating in the capi scala client) us on the Format field * https://github.com/guardian/content-api-scala-client/blob/master/client/src/main/scala/com.gu.contentapi.client/utils/format/Design.scala */ -type FEDesign = - | 'ArticleDesign' - | 'PictureDesign' - | 'GalleryDesign' - | 'AudioDesign' - | 'VideoDesign' - | 'CrosswordDesign' - | 'ReviewDesign' - | 'AnalysisDesign' - | 'CommentDesign' - | 'ExplainerDesign' - | 'LetterDesign' - | 'FeatureDesign' - | 'LiveBlogDesign' - | 'DeadBlogDesign' - | 'RecipeDesign' - | 'MatchReportDesign' - | 'InterviewDesign' - | 'EditorialDesign' - | 'QuizDesign' - | 'InteractiveDesign' - | 'PhotoEssayDesign' - | 'ObituaryDesign' - | 'FullPageInteractiveDesign' - | 'NewsletterSignupDesign' - | 'TimelineDesign' - | 'ProfileDesign'; +const FEDesignSchema = union([ + literal('ArticleDesign'), + literal('PictureDesign'), + literal('GalleryDesign'), + literal('AudioDesign'), + literal('VideoDesign'), + literal('CrosswordDesign'), + literal('ReviewDesign'), + literal('AnalysisDesign'), + literal('CommentDesign'), + literal('ExplainerDesign'), + literal('LetterDesign'), + literal('FeatureDesign'), + literal('LiveBlogDesign'), + literal('DeadBlogDesign'), + literal('RecipeDesign'), + literal('MatchReportDesign'), + literal('InterviewDesign'), + literal('EditorialDesign'), + literal('QuizDesign'), + literal('InteractiveDesign'), + literal('PhotoEssayDesign'), + literal('ObituaryDesign'), + literal('FullPageInteractiveDesign'), + literal('NewsletterSignupDesign'), + literal('TimelineDesign'), + literal('ProfileDesign'), +]); /** FEDisplay is the display information passed through from frontend (originating in the capi scala client) and dictates the display style of the content e.g. Immersive https://github.com/guardian/content-api-scala-client/blob/master/client/src/main/scala/com.gu.contentapi.client/utils/format/Display.scala */ -type FEDisplay = - | 'StandardDisplay' - | 'ImmersiveDisplay' - | 'ShowcaseDisplay' - | 'NumberedListDisplay'; +const FEDisplaySchema = union([ + literal('StandardDisplay'), + literal('ImmersiveDisplay'), + literal('ShowcaseDisplay'), + literal('NumberedListDisplay'), +]); /** * FEFormat is the stringified version of Format passed through from Frontend. * It gets converted to the `@guardian/libs` format on platform */ -export type FEFormat = { - design: FEDesign; - theme: FETheme; - display: FEDisplay; -}; +export type FEFormat = Output<typeof FEFormatSchema>; + +export const FEFormatSchema = object({ + design: FEDesignSchema, + theme: FEThemeSchema, + display: FEDisplaySchema, +}); export type FEStoryPackage = { heading: string; diff --git a/dotcom-rendering/src/types/branding.ts b/dotcom-rendering/src/types/branding.ts index 545d84a77fc..c0d45fba724 100644 --- a/dotcom-rendering/src/types/branding.ts +++ b/dotcom-rendering/src/types/branding.ts @@ -1,27 +1,46 @@ +import { + literal, + number, + object, + optional, + type Output, + string, + union, +} from 'valibot'; import type { EditionId } from '../lib/edition'; -type BrandingLogo = { - src: string; - link: string; - label: string; - dimensions: { width: number; height: number }; -}; +export type BrandingLogo = Output<typeof BrandingLogoSchema>; + +export const BrandingLogoSchema = object({ + src: string(), + link: string(), + label: string(), + dimensions: object({ + width: number(), + height: number(), + }), +}); /** * @see https://github.com/guardian/commercial-shared/blob/35cdf4e1/src/main/scala/com/gu/commercial/branding/BrandingType.scala */ -export type BrandingType = - | { name: 'paid-content' } - | { name: 'foundation' } - | { name: 'sponsored' }; - -export interface Branding { - brandingType?: BrandingType; - sponsorName: string; - logo: BrandingLogo; - aboutThisLink: string; - logoForDarkBackground?: BrandingLogo; -} +export type BrandingType = Output<typeof BrandingTypeSchema>; + +export const BrandingTypeSchema = union([ + object({ name: literal('paid-content') }), + object({ name: literal('foundation') }), + object({ name: literal('sponsored') }), +]); + +export type Branding = Output<typeof BrandingSchema>; + +export const BrandingSchema = object({ + brandingType: optional(BrandingTypeSchema), + sponsorName: string(), + logo: BrandingLogoSchema, + aboutThisLink: string(), + logoForDarkBackground: optional(BrandingLogoSchema), +}); export interface EditionBranding { edition: { diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index 9e5d2390e42..9144058d096 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -1,7 +1,17 @@ import { type CrosswordProps } from '@guardian/react-crossword'; +import { literal, type Output, union } from 'valibot'; import type { ArticleFormat } from '../lib/articleFormat'; -export type StarRating = 0 | 1 | 2 | 3 | 4 | 5; +export const StarRatingSchema = union([ + literal(0), + literal(1), + literal(2), + literal(3), + literal(4), + literal(5), +]); + +export type StarRating = Output<typeof StarRatingSchema>; export type BoostLevel = 'default' | 'boost' | 'megaboost' | 'gigaboost'; diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index df5258d3130..f984adc8b20 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -1,3 +1,4 @@ +import { object, optional, type Output, string } from 'valibot'; import type { FEAspectRatio, FEContainer, @@ -108,11 +109,13 @@ export type DCRSlideshowImage = { imageCaption?: string; }; -export type DCRSnapType = { - embedHtml?: string; - embedCss?: string; - embedJs?: string; -}; +export type DCRSnapType = Output<typeof DCRSnapTypeSchema>; + +export const DCRSnapTypeSchema = object({ + embedHtml: optional(string()), + embedCss: optional(string()), + embedJs: optional(string()), +}); export type AspectRatio = FEAspectRatio; diff --git a/dotcom-rendering/src/types/mainMedia.ts b/dotcom-rendering/src/types/mainMedia.ts index a13d7d48936..ad854fdac41 100644 --- a/dotcom-rendering/src/types/mainMedia.ts +++ b/dotcom-rendering/src/types/mainMedia.ts @@ -1,43 +1,55 @@ -import type { PodcastSeriesImage } from './tag'; - -type Media = { - type: 'Video' | 'LoopVideo' | 'Audio' | 'Gallery'; -}; - +import { + boolean, + literal, + number, + object, + optional, + type Output, + string, + union, +} from 'valibot'; +import { PodcastSeriesImageSchema } from './tag'; /** For displaying embedded, playable videos directly in cards */ -type Video = Media & { - type: 'Video'; +const VideoSchema = object({ + type: literal('Video'), /** @see https://github.com/guardian/frontend/blob/8e7e4d0e/common/app/model/content/Atom.scala#L159 */ - id: string; - videoId: string; - height: number; - width: number; - origin: string; - title: string; - duration: number; - expired: boolean; - image?: string; -}; + id: string(), + videoId: string(), + height: number(), + width: number(), + origin: string(), + title: string(), + duration: number(), + expired: boolean(), + image: optional(string()), +}); + +const LoopVideoSchema = object({ + type: literal('LoopVideo'), + atomId: string(), + videoId: string(), + height: number(), + width: number(), + duration: number(), + image: optional(string()), +}); -type LoopVideo = Media & { - type: 'LoopVideo'; - atomId: string; - videoId: string; - height: number; - width: number; - duration: number; - image?: string; -}; +const AudioSchema = object({ + type: literal('Audio'), + duration: string(), + podcastImage: optional(PodcastSeriesImageSchema), +}); -type Audio = Media & { - type: 'Audio'; - duration: string; - podcastImage?: PodcastSeriesImage; -}; +const GallerySchema = object({ + type: literal('Gallery'), + count: string(), +}); -type Gallery = Media & { - type: 'Gallery'; - count: string; -}; +export type MainMedia = Output<typeof MainMediaSchema>; -export type MainMedia = Video | LoopVideo | Audio | Gallery; +export const MainMediaSchema = union([ + VideoSchema, + LoopVideoSchema, + AudioSchema, + GallerySchema, +]); diff --git a/dotcom-rendering/src/types/tag.ts b/dotcom-rendering/src/types/tag.ts index aca68f30f28..32fd8a2be06 100644 --- a/dotcom-rendering/src/types/tag.ts +++ b/dotcom-rendering/src/types/tag.ts @@ -1,3 +1,5 @@ +import { object, optional, type Output, string } from 'valibot'; + /** * This type comes from `frontend`, hence the FE prefix. * @@ -43,10 +45,12 @@ export type Podcast = { image?: string; }; -export type PodcastSeriesImage = { - src?: string; - altText?: string; -}; +export type PodcastSeriesImage = Output<typeof PodcastSeriesImageSchema>; + +export const PodcastSeriesImageSchema = object({ + type: optional(string()), + altText: optional(string()), +}); export type TagType = { id: string; diff --git a/dotcom-rendering/src/types/trails.ts b/dotcom-rendering/src/types/trails.ts index a92336e45a8..b8e711939fb 100644 --- a/dotcom-rendering/src/types/trails.ts +++ b/dotcom-rendering/src/types/trails.ts @@ -1,40 +1,55 @@ -import type { FEFormat } from '../frontend/feArticle'; +import { + boolean, + number, + object, + optional, + type Output, + record, + string, +} from 'valibot'; +import { FEFormatSchema } from '../frontend/feArticle'; import type { ArticleFormat } from '../lib/articleFormat'; -import type { Branding } from './branding'; -import type { BoostLevel, StarRating } from './content'; -import type { DCRFrontImage, DCRSnapType, DCRSupportingContent } from './front'; -import type { MainMedia } from './mainMedia'; +import { BrandingSchema } from './branding'; +import { type BoostLevel, StarRatingSchema } from './content'; +import { + type DCRFrontImage, + DCRSnapTypeSchema, + type DCRSupportingContent, +} from './front'; +import { MainMediaSchema } from './mainMedia'; -interface BaseTrailType { - url: string; - headline: string; - webPublicationDate?: string; - avatarUrl?: string; - mediaDuration?: number; - ageWarning?: string; - byline?: string; - showByline?: boolean; - kickerText?: string; - shortUrl?: string; - commentCount?: number; - starRating?: StarRating; - linkText?: string; - branding?: Branding; - isSnap?: boolean; - isCrossword?: boolean; - snapData?: DCRSnapType; - showQuotedHeadline?: boolean; - discussion?: { - isCommentable: boolean; - isClosedForComments: boolean; - discussionId?: string; - }; - mainMedia?: MainMedia; - trailText?: string; - galleryCount?: number; -} +export const DiscussionSchema = object({ + isCommentable: boolean(), + isClosedForComments: boolean(), + discussionId: optional(string()), +}); + +export type BaseTrailType = Output<typeof BaseTrailTypeSchema>; -// export type BaseTrailType = Output<typeof BaseTrailTypeSchema> // TODO +export const BaseTrailTypeSchema = object({ + url: string(), + headline: string(), + webPublicationDate: optional(string()), + avatarUrl: optional(string()), + mediaDuration: optional(number()), + ageWarning: optional(string()), + byline: optional(string()), + showByline: optional(boolean()), + kickerText: optional(string()), + shortUrl: optional(string()), + commentCount: optional(number()), + starRating: optional(StarRatingSchema), + linkText: optional(string()), + branding: optional(BrandingSchema), + isSnap: optional(boolean()), + isCrossword: optional(boolean()), + snapData: optional(DCRSnapTypeSchema), + showQuotedHeadline: optional(boolean()), + discussion: optional(DiscussionSchema), + mainMedia: optional(MainMediaSchema), + trailText: optional(string()), + galleryCount: optional(number()), +}); export interface TrailType extends BaseTrailType { palette?: never; @@ -49,23 +64,26 @@ export interface TrailType extends BaseTrailType { image?: DCRFrontImage; } -export interface FETrailType extends BaseTrailType { - format: FEFormat; +export type FETrailType = Output<typeof FETrailTypeSchema>; + +export const FETrailTypeSchema = object({ + ...BaseTrailTypeSchema.entries, + format: FEFormatSchema, /** * @deprecated This type must exist as it's passed by frontend, but we shouldn't use it. * We should remove this property upstream in the future */ - designType?: string; + designType: optional(string()), /** * @deprecated This type must exist as it's passed by frontend, but we shouldn't use it. * We should remove this property upstream in the future */ - pillar?: string; - carouselImages?: { [key: string]: string }; - isLiveBlog?: boolean; - masterImage?: string; - image?: string; -} + pillar: optional(string()), + carouselImages: optional(record(string(), string())), + isLiveBlog: optional(boolean()), + masterImage: optional(string()), + image: optional(string()), +}); // export type FETrailType = Output<typeof FETrailTypeSchema>; // TODO diff --git a/dotcom-rendering/src/types/valibotSchemas/branding.ts b/dotcom-rendering/src/types/valibotSchemas/branding.ts deleted file mode 100644 index 8bca34a7172..00000000000 --- a/dotcom-rendering/src/types/valibotSchemas/branding.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { literal, number, object, optional, string, union } from 'valibot'; - -export const BrandingLogoSchema = object({ - src: string(), - link: string(), - label: string(), - dimensions: object({ - width: number(), - height: number(), - }), -}); - -export const BrandingTypeSchema = union([ - object({ name: literal('paid-content') }), - object({ name: literal('foundation') }), - object({ name: literal('sponsored') }), -]); - -export const BrandingSchema = object({ - brandingType: optional(BrandingTypeSchema), - sponsorName: string(), - logo: BrandingLogoSchema, - aboutThisLink: string(), - logoForDarkBackground: optional(BrandingLogoSchema), -}); diff --git a/dotcom-rendering/src/types/valibotSchemas/content.ts b/dotcom-rendering/src/types/valibotSchemas/content.ts deleted file mode 100644 index f9551a948cc..00000000000 --- a/dotcom-rendering/src/types/valibotSchemas/content.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { literal, union } from 'valibot'; - -// StarRating is 0 | 1 | 2 | 3 | 4 | 5 -export const StarRatingSchema = union([ - literal(0), - literal(1), - literal(2), - literal(3), - literal(4), - literal(5), -]); diff --git a/dotcom-rendering/src/types/valibotSchemas/feFormat.ts b/dotcom-rendering/src/types/valibotSchemas/feFormat.ts deleted file mode 100644 index 179a2fa0985..00000000000 --- a/dotcom-rendering/src/types/valibotSchemas/feFormat.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { literal, object, union } from 'valibot'; - -export const FEFormatSchema = object({ - design: union([ - literal('ArticleDesign'), - literal('PictureDesign'), - literal('GalleryDesign'), - literal('AudioDesign'), - literal('VideoDesign'), - literal('CrosswordDesign'), - literal('ReviewDesign'), - literal('AnalysisDesign'), - literal('CommentDesign'), - literal('ExplainerDesign'), - literal('LetterDesign'), - literal('FeatureDesign'), - literal('LiveBlogDesign'), - literal('DeadBlogDesign'), - literal('RecipeDesign'), - literal('MatchReportDesign'), - literal('InterviewDesign'), - literal('EditorialDesign'), - literal('QuizDesign'), - literal('InteractiveDesign'), - literal('PhotoEssayDesign'), - literal('ObituaryDesign'), - literal('FullPageInteractiveDesign'), - literal('NewsletterSignupDesign'), - literal('TimelineDesign'), - literal('ProfileDesign'), - ]), - theme: union([ - literal('NewsPillar'), - literal('OpinionPillar'), - literal('SportPillar'), - literal('CulturePillar'), - literal('LifestylePillar'), - literal('SpecialReportTheme'), - literal('Labs'), - literal('SpecialReportAltTheme'), - ]), - display: union([ - literal('StandardDisplay'), - literal('ImmersiveDisplay'), - literal('ShowcaseDisplay'), - literal('NumberedListDisplay'), - ]), -}); diff --git a/dotcom-rendering/src/types/valibotSchemas/front.ts b/dotcom-rendering/src/types/valibotSchemas/front.ts deleted file mode 100644 index b5943fb531d..00000000000 --- a/dotcom-rendering/src/types/valibotSchemas/front.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { object, optional, string } from 'valibot'; - -export const DCRSnapTypeSchema = object({ - embedHtml: optional(string()), - embedCss: optional(string()), - embedJs: optional(string()), -}); diff --git a/dotcom-rendering/src/types/valibotSchemas/mainMedia.ts b/dotcom-rendering/src/types/valibotSchemas/mainMedia.ts deleted file mode 100644 index fa808766b0a..00000000000 --- a/dotcom-rendering/src/types/valibotSchemas/mainMedia.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - boolean, - literal, - number, - object, - optional, - string, - union, -} from 'valibot'; -import { PodcastSeriesImageSchema } from './tag'; - -const VideoSchema = object({ - type: literal('Video'), - id: string(), - videoId: string(), - height: number(), - width: number(), - origin: string(), - title: string(), - duration: number(), - expired: boolean(), - image: optional(string()), -}); - -const LoopVideoSchema = object({ - type: literal('LoopVideo'), - atomId: string(), - videoId: string(), - height: number(), - width: number(), - duration: number(), - image: optional(string()), -}); - -const AudioSchema = object({ - type: literal('Audio'), - duration: string(), - podcastImage: optional(PodcastSeriesImageSchema), -}); - -const GallerySchema = object({ - type: literal('Gallery'), - count: string(), -}); - -export const MainMediaSchema = union([ - VideoSchema, - LoopVideoSchema, - AudioSchema, - GallerySchema, -]); diff --git a/dotcom-rendering/src/types/valibotSchemas/tag.ts b/dotcom-rendering/src/types/valibotSchemas/tag.ts deleted file mode 100644 index 62bf7939611..00000000000 --- a/dotcom-rendering/src/types/valibotSchemas/tag.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { object, optional, string } from 'valibot'; - -export const PodcastSeriesImageSchema = object({ - type: optional(string()), - altText: optional(string()), -}); diff --git a/dotcom-rendering/src/types/valibotSchemas/trails.ts b/dotcom-rendering/src/types/valibotSchemas/trails.ts deleted file mode 100644 index 7cc34965890..00000000000 --- a/dotcom-rendering/src/types/valibotSchemas/trails.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { boolean, number, object, optional, record, string } from 'valibot'; -import { BrandingSchema } from './branding'; -import { StarRatingSchema } from './content'; -import { FEFormatSchema } from './feFormat'; -import { DCRSnapTypeSchema } from './front'; -import { MainMediaSchema } from './mainMedia'; - -export const DiscussionSchema = object({ - isCommentable: boolean(), - isClosedForComments: boolean(), - discussionId: optional(string()), -}); - -export const BaseTrailTypeSchema = object({ - url: string(), - headline: string(), - webPublicationDate: optional(string()), - avatarUrl: optional(string()), - mediaDuration: optional(number()), - ageWarning: optional(string()), - byline: optional(string()), - showByline: optional(boolean()), - kickerText: optional(string()), - shortUrl: optional(string()), - commentCount: optional(number()), - starRating: optional(StarRatingSchema), - linkText: optional(string()), - branding: optional(BrandingSchema), - isSnap: optional(boolean()), - isCrossword: optional(boolean()), - snapData: optional(DCRSnapTypeSchema), - showQuotedHeadline: optional(boolean()), - discussion: optional(DiscussionSchema), - mainMedia: optional(MainMediaSchema), - trailText: optional(string()), - galleryCount: optional(number()), -}); - -export const FETrailTypeSchema = object({ - ...BaseTrailTypeSchema.entries, - format: FEFormatSchema, - designType: optional(string()), - pillar: optional(string()), - carouselImages: optional(record(string(), string())), - isLiveBlog: optional(boolean()), - masterImage: optional(string()), - image: optional(string()), -}); From 77a7d81f4c202e3ceb6d0ca1f1635fc952de164d Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Fri, 5 Sep 2025 18:18:14 +0100 Subject: [PATCH 09/10] Update Placeholder to accept heights for different breakpoints Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com> --- .../components/DiscussionWeb.importable.tsx | 2 +- .../FetchMoreGalleriesData.importable.tsx | 13 ++++- .../FetchOnwardsData.importable.tsx | 2 +- .../GetCricketScoreboard.importable.tsx | 2 +- .../components/GetMatchStats.importable.tsx | 2 +- .../components/GetMatchTabs.importable.tsx | 2 +- .../InteractiveBlockComponent.importable.tsx | 2 +- .../src/components/LeftColumn.stories.tsx | 4 +- .../src/components/Placeholder.stories.tsx | 49 ++++++++++++++----- .../src/components/Placeholder.tsx | 40 ++++++++++----- 10 files changed, 87 insertions(+), 31 deletions(-) diff --git a/dotcom-rendering/src/components/DiscussionWeb.importable.tsx b/dotcom-rendering/src/components/DiscussionWeb.importable.tsx index a107c244bd0..81f4e9228b0 100644 --- a/dotcom-rendering/src/components/DiscussionWeb.importable.tsx +++ b/dotcom-rendering/src/components/DiscussionWeb.importable.tsx @@ -103,7 +103,7 @@ export const DiscussionWeb = ( }); }, [authStatus, props.discussionApiUrl]); - if (!hydrated) return <Placeholder height={324} />; + if (!hydrated) return <Placeholder heights={new Map([['mobile', 324]])} />; return ( <Discussion diff --git a/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx index edbde64c395..c83db8a3960 100644 --- a/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx +++ b/dotcom-rendering/src/components/FetchMoreGalleriesData.importable.tsx @@ -138,7 +138,18 @@ export const FetchMoreGalleriesData = ({ if (!data?.trails) { return ( <Placeholder - height={720} // best guess at typical height // TODO: this is different value for different breakpoints!!! + heights={ + new Map([ + ['mobile', 1020], + ['mobileMedium', 1040], + ['mobileLandscape', 1100], + ['phablet', 1200], + ['tablet', 700], + ['desktop', 800], + ['leftCol', 740], + ['wide', 790], + ]) + } shouldShimmer={false} backgroundColor={palette('--onward-background')} /> diff --git a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx index 3c97f3b51e6..589cc177579 100644 --- a/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx +++ b/dotcom-rendering/src/components/FetchOnwardsData.importable.tsx @@ -68,7 +68,7 @@ export const FetchOnwardsData = ({ if (!data?.trails) { return ( <Placeholder - height={340} // best guess at typical height + heights={new Map([['mobile', 340]])} // best guess at typical height shouldShimmer={false} backgroundColor={palette('--article-background')} /> diff --git a/dotcom-rendering/src/components/GetCricketScoreboard.importable.tsx b/dotcom-rendering/src/components/GetCricketScoreboard.importable.tsx index 41c59c3810f..cbb1fe47c27 100644 --- a/dotcom-rendering/src/components/GetCricketScoreboard.importable.tsx +++ b/dotcom-rendering/src/components/GetCricketScoreboard.importable.tsx @@ -10,7 +10,7 @@ type Props = { format: ArticleFormat; }; -const Loading = () => <Placeholder height={172} />; +const Loading = () => <Placeholder heights={new Map([['mobile', 172]])} />; export const GetCricketScoreboard = ({ matchUrl, format }: Props) => { const options: SWRConfiguration = { diff --git a/dotcom-rendering/src/components/GetMatchStats.importable.tsx b/dotcom-rendering/src/components/GetMatchStats.importable.tsx index 747c55626d5..40ec17d2ee7 100644 --- a/dotcom-rendering/src/components/GetMatchStats.importable.tsx +++ b/dotcom-rendering/src/components/GetMatchStats.importable.tsx @@ -11,7 +11,7 @@ type Props = { format: ArticleFormat; }; -const Loading = () => <Placeholder height={800} />; +const Loading = () => <Placeholder heights={new Map([['mobile', 800]])} />; const cleanTeamCodes = ({ name, diff --git a/dotcom-rendering/src/components/GetMatchTabs.importable.tsx b/dotcom-rendering/src/components/GetMatchTabs.importable.tsx index 42ffd60fde6..792d4ee3e5f 100644 --- a/dotcom-rendering/src/components/GetMatchTabs.importable.tsx +++ b/dotcom-rendering/src/components/GetMatchTabs.importable.tsx @@ -8,7 +8,7 @@ type Props = { format: ArticleFormat; }; -const Loading = () => <Placeholder height={40} />; +const Loading = () => <Placeholder heights={new Map([['mobile', 40]])} />; /** * ## Why does this need to be an Island? diff --git a/dotcom-rendering/src/components/InteractiveBlockComponent.importable.tsx b/dotcom-rendering/src/components/InteractiveBlockComponent.importable.tsx index 8dcb6e9c5f9..460600e7e7e 100644 --- a/dotcom-rendering/src/components/InteractiveBlockComponent.importable.tsx +++ b/dotcom-rendering/src/components/InteractiveBlockComponent.importable.tsx @@ -459,7 +459,7 @@ export const InteractiveBlockComponent = ({ {!loaded && ( <> <Placeholder // removed by HydrateInteractiveOnce - height={decideHeight(role)} + heights={new Map([['mobile', decideHeight(role)]])} shouldShimmer={false} /> <a diff --git a/dotcom-rendering/src/components/LeftColumn.stories.tsx b/dotcom-rendering/src/components/LeftColumn.stories.tsx index f1f43ce13a5..55b5defec1e 100644 --- a/dotcom-rendering/src/components/LeftColumn.stories.tsx +++ b/dotcom-rendering/src/components/LeftColumn.stories.tsx @@ -29,7 +29,7 @@ export const PartialRightBorder = () => { }} > <Placeholder - height={500} + heights={new Map([['mobile', 500]])} width={600} shouldShimmer={false} /> @@ -58,7 +58,7 @@ export const RightBorder = () => { }} > <Placeholder - height={500} + heights={new Map([['mobile', 500]])} width={600} shouldShimmer={false} /> diff --git a/dotcom-rendering/src/components/Placeholder.stories.tsx b/dotcom-rendering/src/components/Placeholder.stories.tsx index 575ab8209d3..75c3d441268 100644 --- a/dotcom-rendering/src/components/Placeholder.stories.tsx +++ b/dotcom-rendering/src/components/Placeholder.stories.tsx @@ -42,7 +42,7 @@ export default { export const Basic = () => { return ( <Wrapper> - <Placeholder height={200} /> + <Placeholder heights={new Map([['mobile', 200]])} /> </Wrapper> ); }; @@ -51,7 +51,7 @@ Basic.storyName = 'with 200px height'; export const Square = () => { return ( <Wrapper> - <Placeholder height={200} width={200} /> + <Placeholder heights={new Map([['mobile', 200]])} width={200} /> </Wrapper> ); }; @@ -61,9 +61,21 @@ export const InARow = () => { return ( <Wrapper> <Row> - <Placeholder height={200} width={200} spaceLeft={2} /> - <Placeholder height={200} width={200} spaceLeft={2} /> - <Placeholder height={200} width={200} spaceLeft={2} /> + <Placeholder + heights={new Map([['mobile', 200]])} + width={200} + spaceLeft={2} + /> + <Placeholder + heights={new Map([['mobile', 200]])} + width={200} + spaceLeft={2} + /> + <Placeholder + heights={new Map([['mobile', 200]])} + width={200} + spaceLeft={2} + /> </Row> </Wrapper> ); @@ -74,9 +86,18 @@ export const Stacked = () => { return ( <Wrapper> <Column> - <Placeholder height={200} spaceBelow={5} /> - <Placeholder height={200} spaceBelow={5} /> - <Placeholder height={200} spaceBelow={5} /> + <Placeholder + heights={new Map([['mobile', 200]])} + spaceBelow={5} + /> + <Placeholder + heights={new Map([['mobile', 200]])} + spaceBelow={5} + /> + <Placeholder + heights={new Map([['mobile', 200]])} + spaceBelow={5} + /> </Column> </Wrapper> ); @@ -86,7 +107,10 @@ Stacked.storyName = 'with elements stacked'; export const Root = () => { return ( <Wrapper> - <Placeholder height={200} rootId="usedWithPortals" /> + <Placeholder + heights={new Map([['mobile', 200]])} + rootId="usedWithPortals" + /> </Wrapper> ); }; @@ -95,7 +119,10 @@ Root.storyName = 'with rootId set'; export const NoShimmer = () => { return ( <Wrapper> - <Placeholder height={200} shouldShimmer={false} /> + <Placeholder + heights={new Map([['mobile', 200]])} + shouldShimmer={false} + /> </Wrapper> ); }; @@ -105,7 +132,7 @@ export const Background = () => { return ( <Wrapper> <Placeholder - height={200} + heights={new Map([['mobile', 200]])} shouldShimmer={true} backgroundColor="#ffff00" /> diff --git a/dotcom-rendering/src/components/Placeholder.tsx b/dotcom-rendering/src/components/Placeholder.tsx index 52524719d48..83a2cf9b90e 100644 --- a/dotcom-rendering/src/components/Placeholder.tsx +++ b/dotcom-rendering/src/components/Placeholder.tsx @@ -1,11 +1,16 @@ import { css, keyframes } from '@emotion/react'; import { isUndefined } from '@guardian/libs'; -import { palette, space } from '@guardian/source/foundations'; +import { + type Breakpoint, + from, + palette, + space, +} from '@guardian/source/foundations'; const BACKGROUND_COLOUR = palette.neutral[93]; type Props = { - height: number; + heights: Map<Breakpoint, number>; rootId?: string; width?: number; spaceBelow?: 1 | 2 | 3 | 4 | 5 | 6 | 9; @@ -34,8 +39,19 @@ const shimmerStyles = (backgroundColor: string) => css` background-size: 1500px 100%; `; +const heightsMediaQueries = (heights: Map<Breakpoint, number>) => + css( + Array.from(heights.entries()).map( + ([breakpoint, height]: [Breakpoint, number]) => css` + ${from[breakpoint]} { + min-height: ${height}px; + } + `, + ), + ); + export const Placeholder = ({ - height, + heights, rootId, width, spaceBelow, @@ -51,15 +67,17 @@ export const Placeholder = ({ data-name="placeholder" > <div - css={css` - min-height: ${height}px; - width: ${!isUndefined(width) ? `${width}px` : '100%'}; - margin-bottom: ${spaceBelow && space[spaceBelow]}px; - margin-left: ${spaceLeft && space[spaceLeft]}px; - background-color: ${backgroundColor}; + css={[ + heightsMediaQueries(heights), + css` + width: ${!isUndefined(width) ? `${width}px` : '100%'}; + margin-bottom: ${spaceBelow && space[spaceBelow]}px; + margin-left: ${spaceLeft && space[spaceLeft]}px; + background-color: ${backgroundColor}; - ${shouldShimmer && shimmerStyles(backgroundColor)} - `} + ${shouldShimmer && shimmerStyles(backgroundColor)} + `, + ]} /> </div> ); From 6133f7ea368a857633d8956ae8f39176317a5947 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <marjan.kalanaki@guardian.co.uk> Date: Mon, 22 Sep 2025 08:28:53 +0100 Subject: [PATCH 10/10] fix field name for PodcastSeriesImage type --- dotcom-rendering/src/types/tag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotcom-rendering/src/types/tag.ts b/dotcom-rendering/src/types/tag.ts index 32fd8a2be06..bd3a456955a 100644 --- a/dotcom-rendering/src/types/tag.ts +++ b/dotcom-rendering/src/types/tag.ts @@ -48,7 +48,7 @@ export type Podcast = { export type PodcastSeriesImage = Output<typeof PodcastSeriesImageSchema>; export const PodcastSeriesImageSchema = object({ - type: optional(string()), + src: optional(string()), altText: optional(string()), });