diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index c3a4e67dd2..aa77483cee 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx @@ -1,8 +1,7 @@ import type { ReactElement, Ref } from 'react'; import React, { forwardRef, useRef } from 'react'; -import classNames from 'classnames'; import type { PostCardProps } from '../common/common'; -import { Container, generateTitleClamp } from '../common/common'; +import { Container } from '../common/common'; import { usePostImage } from '../../../hooks/post/usePostImage'; import FeedItemContainer from '../common/FeedItemContainer'; import { @@ -21,6 +20,7 @@ import { glassCoverImageClassName, } from '../common/FeedCardGlassActions'; import { ClickbaitShield } from '../common/ClickbaitShield'; +import PostTags from '../common/PostTags'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; @@ -90,36 +90,22 @@ export const FreeformGrid = forwardRef(function SharePostCard( post={post} enableSourceHeader={enableSourceHeader} /> - - {title} - + {title} - <> - {image && } -
+ {/* Match the collection card: push the tags + date to the bottom of the + text area, just above the footer (cover image or text preview). */} + + +
{post.clickbaitTitleDetected && } +
- +
{useGlass ? ( {post.clickbaitTitleDetected && } +
+
{!isMobile && actionButtons}
diff --git a/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx b/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx index 3572651586..2338b7ff68 100644 --- a/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx +++ b/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx @@ -1,8 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; -import { CollectionPillSources } from '../../post/collection/CollectionPillSources'; -import { getCollectionPillLabel } from '../../post/collection/common'; +import { CollectionSourceStack } from '../../post/collection/CollectionSourceStack'; import { BookmakProviderHeader, headerHiddenClassName, @@ -31,23 +30,24 @@ export const CollectionCardHeader = ({ return ( <> {highlightBookmarkedPost && } - +
- +
); }; diff --git a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx index 7a7b76f8da..bca38d9276 100644 --- a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx @@ -94,7 +94,6 @@ export const CollectionFeaturedWideGridCard = forwardRef( wasUpdated ? TimeFormatType.PostUpdated : TimeFormatType.Post } readTime={post.readTime} - numSources={post.numCollectionSources} className="mt-1" /> {!!post.summary && ( diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx index e2bafbcb4f..e2b1c53a60 100644 --- a/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx +++ b/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx @@ -107,20 +107,31 @@ it('should hide read time when not available', async () => { expect(screen.queryByTestId('readTime')).not.toBeInTheDocument(); }); -it('should display the `collection` pill in the header', async () => { - renderComponent(); - await screen.findByText('Collection'); -}); - -it('should display the `Trend` pill when the post source is trends', async () => { +it('should display the collection source stack in the header', async () => { renderComponent({ post: { ...post, - source: { ...post.source, id: 'trends' } as Post['source'], - }, + collectionSources: [ + { + id: 'src1', + handle: 'src1', + name: 'Source One', + image: 'https://daily.dev/src1.png', + permalink: 'https://daily.dev/sources/src1', + }, + { + id: 'src2', + handle: 'src2', + name: 'Source Two', + image: 'https://daily.dev/src2.png', + permalink: 'https://daily.dev/sources/src2', + }, + ], + numCollectionSources: 2, + } as Post, }); - await screen.findByText('Trend'); - expect(screen.queryByText('Collection')).not.toBeInTheDocument(); + await screen.findByAltText('Avatar of src1'); + await screen.findByAltText('Avatar of src2'); }); it('should show options button on hover when in laptop size', async () => { diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.tsx index b9ae3ee8c5..06ef6357d5 100644 --- a/packages/shared/src/components/cards/collection/CollectionGrid.tsx +++ b/packages/shared/src/components/cards/collection/CollectionGrid.tsx @@ -2,13 +2,14 @@ import type { Ref } from 'react'; import React, { forwardRef } from 'react'; import classNames from 'classnames'; import type { PostCardProps } from '../common/common'; -import { Container, generateTitleClamp } from '../common/common'; +import { Container } from '../common/common'; import FeedItemContainer from '../common/FeedItemContainer'; import { CollectionCardHeader } from './CollectionCardHeader'; import { getPostClassNames, FreeformCardTitle, CardTextContainer, + CardSpace, } from '../common/Card'; import { WelcomePostCardFooter } from '../common/WelcomePostCardFooter'; import ActionButtons from '../common/ActionButtons'; @@ -69,6 +70,16 @@ export const CollectionGrid = forwardRef(function CollectionCard( ); } + const postMetadata = ( + + ); + return ( - + {post.title} - - - + {/* Push the tags + date to the bottom of the text area, just above the + footer (cover image or freeform text preview), matching the default + article card so they align regardless of title length. */} + + + + {postMetadata} + {useGlass ? ( - diff --git a/packages/shared/src/components/cards/common/WelcomePostCardFooter.tsx b/packages/shared/src/components/cards/common/WelcomePostCardFooter.tsx index e6e9e810df..4b8fc3e0b7 100644 --- a/packages/shared/src/components/cards/common/WelcomePostCardFooter.tsx +++ b/packages/shared/src/components/cards/common/WelcomePostCardFooter.tsx @@ -13,6 +13,7 @@ interface WelcomePostCardFooterProps { contentHtml?: string; onShare?: (post: Post) => void; imageClassName?: string; + contentClassName?: string; glassActions?: boolean; } @@ -22,6 +23,7 @@ export const WelcomePostCardFooter = ({ onShare, contentHtml, imageClassName, + contentClassName, glassActions = false, }: WelcomePostCardFooterProps): ReactElement | null => { const { overlay } = useCardCover({ @@ -74,6 +76,7 @@ export const WelcomePostCardFooter = ({ // The glass action bar floats over the card bottom. Clamp one line // tighter and reserve its height (pb-12) so text never sits under it. glassActions ? 'line-clamp-5 pb-12' : 'line-clamp-6', + contentClassName, )} > {decodedText} diff --git a/packages/shared/src/components/post/collection/CollectionSourceStack.tsx b/packages/shared/src/components/post/collection/CollectionSourceStack.tsx new file mode 100644 index 0000000000..878e4620ae --- /dev/null +++ b/packages/shared/src/components/post/collection/CollectionSourceStack.tsx @@ -0,0 +1,155 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import dynamic from 'next/dynamic'; +import { SourceAvatar } from '../../profile/source/SourceAvatar'; +import { ProfileImageSize, sizeClasses } from '../../ProfilePicture'; +import HoverCard from '../../cards/common/HoverCard'; +import type { SourceTooltip } from '../../../graphql/sources'; +import { SourceType } from '../../../graphql/sources'; +import { Origin } from '../../../lib/log'; +import { useViewSize, ViewSize } from '../../../hooks'; + +const SquadEntityCard = dynamic( + /* webpackChunkName: "squadEntityCard" */ () => + import('../../cards/entity/SquadEntityCard'), +); + +const SourceEntityCard = dynamic( + /* webpackChunkName: "sourceEntityCard" */ () => + import('../../cards/entity/SourceEntityCard'), +); + +// Resting stack shows at most 3 avatars + one "+N" counter = 4 circles. +const MAX_AVATARS = 3; +// Open spacing matches how sources sit today (~6px overlap, i.e. -ml-1.5). +const OPEN_OVERLAP_PX = 6; +// While resting the circles overlap ~62% of their width for a compact mark. +const REST_OVERLAP_RATIO = 0.62; + +const avatarPx: Partial> = { + [ProfileImageSize.Small]: 24, + [ProfileImageSize.Medium]: 32, + [ProfileImageSize.Large]: 40, +}; + +interface CollectionSourceStackProps { + sources: SourceTooltip[]; + totalSources: number; + size?: ProfileImageSize; + className?: string; + /** + * Force the stack open (no hover needed). Defaults to true on touch/mobile + * where there is no cursor to hover. + */ + alwaysExpanded?: boolean; + /** + * Show the rich source hover card per avatar. Defaults to true on desktop + * only — touch surfaces have no hover. + */ + enableHoverCard?: boolean; +} + +export const CollectionSourceStack = ({ + sources, + totalSources, + size = ProfileImageSize.Medium, + className, + alwaysExpanded: alwaysExpandedProp, + enableHoverCard: enableHoverCardProp, +}: CollectionSourceStackProps): ReactElement | null => { + const isLaptop = useViewSize(ViewSize.Laptop); + const alwaysExpanded = alwaysExpandedProp ?? !isLaptop; + const enableHoverCard = enableHoverCardProp ?? isLaptop; + + if (!sources?.length) { + return null; + } + + const shown = sources.slice(0, MAX_AVATARS); + const remaining = Math.max(totalSources - shown.length, 0); + const px = avatarPx[size] ?? 32; + const restStep = alwaysExpanded + ? OPEN_OVERLAP_PX + : Math.round(px * REST_OVERLAP_RATIO); + + // margin-left is applied through a class reading a CSS var (never inline), so + // the group-hover variant can win. Inline margin would always beat the class. + const marginClass = + 'ml-[var(--ml-rest)] group-hover/srcstack:ml-[var(--ml-open)]'; + + const circleStyle = (index: number): React.CSSProperties => + ({ + '--ml-rest': `${index === 0 ? 0 : -restStep}px`, + '--ml-open': `${index === 0 ? 0 : -OPEN_OVERLAP_PX}px`, + transitionProperty: 'margin-left', + transitionDuration: '260ms', + transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)', + } as React.CSSProperties); + + const withHoverCard = ( + source: SourceTooltip, + avatar: ReactElement, + ): ReactElement => { + if (!enableHoverCard) { + return {avatar}; + } + + return ( + + {source.type === SourceType.Squad ? ( + + ) : ( + + )} + + ); + }; + + return ( + // `pointer-events-auto` re-enables hover: the card's text container is + // `pointer-events-none` so post clicks fall through to the card link, and + // that inherits down to the stack. +
+ {shown.map((source, index) => + withHoverCard( + source, +
+ +
, + ), + )} + {remaining > 0 && ( +
+ +{remaining} +
+ )} +
+ ); +}; diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 9fbff7cb17..e955fed775 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -670,8 +670,17 @@ export const FEED_POST_FRAGMENT = gql` trending feedMeta collectionSources { + id handle + name image + permalink + type + description + membersCount + flags { + totalUpvotes + } } numCollectionSources updatedAt diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 7611db71cb..fbbd7f3d3f 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -408,8 +408,17 @@ export const POST_BY_ID_QUERY = gql` updatedAt numCollectionSources collectionSources { + id handle + name image + permalink + type + description + membersCount + flags { + totalUpvotes + } } } relatedCollectionPosts: relatedPosts( @@ -495,8 +504,17 @@ export const POST_BY_ID_STATIC_FIELDS_QUERY = gql` updatedAt numCollectionSources collectionSources { + id handle + name image + permalink + type + description + membersCount + flags { + totalUpvotes + } } sharedPost { ...SharedPostInfo