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