diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 73a28276350..94224f345f0 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 => { @@ -589,7 +591,7 @@ export const Card = ({ }; const hideTrailTextUntil = () => { - if (isFlexibleContainer) { + if (isFlexibleContainer || (isOnwardContainer && isFlexSplash)) { return undefined; } else if ( imageSize === 'large' && @@ -602,6 +604,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 +1096,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 = (
; + if (!hydrated) return ; return ( ; + +const MoreGalleriesResponseSchema = object({ + trails: array(FETrailTypeSchema), + heading: string(), +}); + +const minHeight = css` + min-height: 300px; +`; + +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, + isAdFreeUser: boolean, +): TrailType[] => { + return trails + .filter( + (trailType) => + !( + trailType.branding?.brandingType?.name === 'paid-content' && + isAdFreeUser + ), + ) + .slice(0, trailLimit) + .map(toGalleryTrail); +}; + +const fetchJson = async (ajaxUrl: string): Promise => { + 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( + undefined, + ); + const [error, setError] = useState(undefined); + + useEffect(() => { + fetchJson(url) + .then((fetchedData) => { + setData(fetchedData); + setError(undefined); + }) + .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 undefined; + } + + if (!data?.trails) { + return ( + + ); + } + + addDiscussionIds( + data.trails + .map((trail) => trail.discussion?.discussionId) + .filter(isNonNullable), + ); + + return ( +
+ +
+ ); +}; 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 ( 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 = () => ; +const Loading = () => ; 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 = () => ; +const Loading = () => ; 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 = () => ; +const Loading = () => ; /** * ## 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 && ( <> { }} > @@ -58,7 +58,7 @@ export const RightBorder = () => { }} > diff --git a/dotcom-rendering/src/components/MoreGalleries.stories.tsx b/dotcom-rendering/src/components/MoreGalleries.stories.tsx new file mode 100644 index 00000000000..4ca6afa0232 --- /dev/null +++ b/dotcom-rendering/src/components/MoreGalleries.stories.tsx @@ -0,0 +1,209 @@ +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'; + +const meta = { + title: 'Components/MoreGalleries', + component: MoreGalleriesComponent, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const MoreGalleries = { + args: { + 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', + 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, + ), + 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', + linkText: + 'Characterful cottages for sale in England – in pictures', + showByline: false, + byline: 'Anna White', + image: { + src: 'https://media.guim.co.uk/58cd9356e6d68e8efa6028162bb959f9798307d5/515_0_5000_4000/master/5000.jpg', + altText: '', + }, + format: { + 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', + 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', + linkText: + 'Sunsets, aid parachutes and giant pandas: photos of the day – Friday ', + showByline: false, + byline: 'Eithne Staunton', + image: { + src: 'https://media.guim.co.uk/4ce0b080206fe9b65b976c1acf219d81072cc814/0_0_2113_1690/master/2113.png', + altText: '', + }, + format: { + 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 ', + 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', + image: { + src: 'https://media.guim.co.uk/49a9656cd10c4f64f8bdd54380afb915c7a3648b/207_0_1500_1200/master/1500.jpg', + altText: '', + }, + format: { + 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', + 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, + image: { + src: 'https://media.guim.co.uk/2810af61b2d2d2d5f71ec01e56e6555e0a6d4635/55_0_2813_2250/master/2813.jpg', + altText: '', + }, + format: { + 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 ', + 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 + }, + ], + }, +} satisfies Story; diff --git a/dotcom-rendering/src/components/MoreGalleries.tsx b/dotcom-rendering/src/components/MoreGalleries.tsx new file mode 100644 index 00000000000..e2abb5fcf97 --- /dev/null +++ b/dotcom-rendering/src/components/MoreGalleries.tsx @@ -0,0 +1,270 @@ +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 = { + 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 ( +
+
+ + + </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: 'medium', + 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/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> ); 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/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": [ diff --git a/dotcom-rendering/src/layouts/GalleryLayout.tsx b/dotcom-rendering/src/layouts/GalleryLayout.tsx index 1fc81d18e94..07a2581e2df 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 { FetchMoreGalleriesData } from '../components/FetchMoreGalleriesData.importable'; import { Footer } from '../components/Footer'; import { DesktopAdSlot, MobileAdSlot } from '../components/GalleryAdSlots'; import { GalleryImage } from '../components/GalleryImage'; @@ -379,6 +380,19 @@ 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' }}> + <FetchMoreGalleriesData + url={`${gallery.frontendData.config.ajaxUrl}/gallery/most-viewed.json?dcr=true`} + limit={5} + onwardsSource={'more-galleries'} + discussionApiUrl={discussionApiUrl} + absoluteServerTimes={ + switches['absoluteServerTimes'] ?? false + } + isAdFreeUser={frontendData.isAdFreeUser} + /> + </Island> </main> {/* More galleries container */} {showMerchandisingHigh && ( 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/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/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/tag.ts b/dotcom-rendering/src/types/tag.ts index aca68f30f28..bd3a456955a 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({ + src: 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 396a31072c7..b8e711939fb 100644 --- a/dotcom-rendering/src/types/trails.ts +++ b/dotcom-rendering/src/types/trails.ts @@ -1,36 +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; -} +export const DiscussionSchema = object({ + isCommentable: boolean(), + isClosedForComments: boolean(), + discussionId: optional(string()), +}); + +export type BaseTrailType = Output<typeof BaseTrailTypeSchema>; + +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; @@ -45,23 +64,28 @@ 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 export interface TrailTabType { heading: string;