diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index c3a4e67dd2b..a6e3a09820f 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 35726515867..2338b7ff68b 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 7a7b76f8da6..bca38d92762 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 e2bafbcb4f8..e2b1c53a60c 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 b9ae3ee8c59..baa42f46501 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/AuthorSourceStack.tsx b/packages/shared/src/components/cards/common/AuthorSourceStack.tsx new file mode 100644 index 00000000000..042e2be93c2 --- /dev/null +++ b/packages/shared/src/components/cards/common/AuthorSourceStack.tsx @@ -0,0 +1,140 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import dynamic from 'next/dynamic'; +import type { Author } from '../../../graphql/comments'; +import type { Source, SourceTooltip } from '../../../graphql/sources'; +import { isSourceUserSource } from '../../../graphql/sources'; +import { ProfileImageSize, roundClasses } from '../../ProfilePicture'; +import { ProfileImageLink } from '../../profile/ProfileImageLink'; +import SourceButton from './SourceButton'; +import { useViewSize, ViewSize } from '../../../hooks'; + +const HoverCard = dynamic( + /* webpackChunkName: "hoverCard" */ () => import('./HoverCard'), +); + +// Lazy: the author's profile is fetched on hover, so the feed stays lean. +const LazyUserEntityCard = dynamic( + /* webpackChunkName: "lazyUserEntityCard" */ () => + import('../entity/LazyUserEntityCard').then((m) => m.LazyUserEntityCard), +); + +// Overlap for the author + source stack — mirrors CollectionSourceStack: it +// rests tight and opens up on hover. +const OPEN_OVERLAP_PX = 6; +const REST_OVERLAP_RATIO = 0.62; + +const avatarPx: Partial> = { + [ProfileImageSize.Small]: 24, + [ProfileImageSize.Medium]: 32, + [ProfileImageSize.Large]: 40, +}; + +// Ring separating the overlapping avatars from the card behind them. +const avatarRing = 'ring-2 ring-background-default'; + +// margin-left is applied through a class reading a CSS var (never inline) so the +// group-hover variant can win and the stack opens up on hover. +const marginClass = + 'ml-[var(--ml-rest)] group-hover/authstack:ml-[var(--ml-open)]'; + +interface AuthorSourceStackProps { + author?: Author | null; + source?: Source | null; + size?: ProfileImageSize; + className?: string; +} + +/** + * Renders a post's author + source as an overlapping stack matching the + * collection source stack: the author (primary, a rounded-square user avatar) + * sits in front on the left, the source/squad (secondary, a circle) tucks in + * behind on the right, and the stack opens on hover. Each avatar keeps its own + * hover card (author → profile, source → source/squad). + * + * Falls back to a single bare avatar when only one is present (so article + * cards, which have a source and no author, render exactly as before). + */ +export const AuthorSourceStack = ({ + author, + source, + size = ProfileImageSize.Medium, + className, +}: AuthorSourceStackProps): ReactElement | null => { + const alwaysExpanded = !useViewSize(ViewSize.Laptop); + const showSource = !!source && !isSourceUserSource(source); + + if (!author && !showSource) { + return null; + } + + const authorAvatar = author ? ( + } + > + + + ) : null; + + const sourceAvatar = showSource ? ( + + ) : null; + + // Single avatar: render it bare (no ring / overlap) — keeps non-author cards + // (e.g. articles) pixel-identical to before. + if (!authorAvatar || !sourceAvatar) { + return authorAvatar ?? sourceAvatar; + } + + const restStep = alwaysExpanded + ? OPEN_OVERLAP_PX + : Math.round((avatarPx[size] ?? 32) * REST_OVERLAP_RATIO); + + // index 0 = author (front, fixed); index 1 = source (behind, opens on hover). + const overlapStyle = (index: number): 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)', + // zIndex is inline (this build doesn't emit Tailwind z-* utilities) so the + // author (primary) always wins the stacking order over the source. + zIndex: index === 0 ? 2 : 1, + } as CSSProperties); + + return ( +
+ {/* Author avatar: a rounded square, like every user avatar (rounding + matches the ProfilePicture size so the ring hugs the avatar). */} +
+ {authorAvatar} +
+ {/* Source/squad avatar stays circular. */} +
+ {sourceAvatar} +
+
+ ); +}; diff --git a/packages/shared/src/components/cards/common/PostCardHeader.tsx b/packages/shared/src/components/cards/common/PostCardHeader.tsx index 3b0d0a0d922..8014d1c2bc4 100644 --- a/packages/shared/src/components/cards/common/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/PostCardHeader.tsx @@ -1,11 +1,9 @@ import type { ReactElement, ReactNode } from 'react'; import React, { useMemo } from 'react'; import classNames from 'classnames'; -import dynamic from 'next/dynamic'; import { CardHeader } from './Card'; -import SourceButton from './SourceButton'; import type { Source } from '../../../graphql/sources'; -import { isSourceUserSource } from '../../../graphql/sources'; +import { AuthorSourceStack } from './AuthorSourceStack'; import { ReadArticleButton, getReadPostButtonIcon } from './ReadArticleButton'; import { getGroupedHoverContainer } from './common'; import { useBookmarkProvider, useFeedPreviewMode } from '../../../hooks'; @@ -25,20 +23,8 @@ import { BookmakProviderHeader, headerHiddenClassName, } from './BookmarkProviderHeader'; -import { ProfileImageLink } from '../../profile/ProfileImageLink'; -import { ProfileImageSize } from '../../ProfilePicture'; import { DeletedPostId } from '../../../lib/constants'; import { PostOptionButton } from '../../../features/posts/PostOptionButton'; -import type { UserShortProfile } from '../../../lib/user'; - -const HoverCard = dynamic( - /* webpackChunkName: "hoverCard" */ () => import('./HoverCard'), -); - -const UserEntityCard = dynamic( - /* webpackChunkName: "userEntityCard" */ () => - import('../entity/UserEntityCard'), -); interface CardHeaderProps { post: Post; @@ -68,7 +54,6 @@ export const PostCardHeader = ({ }: CardHeaderProps): ReactElement => { const isFeedPreview = useFeedPreviewMode(); const isSharedPostDeleted = post.sharedPost?.id === DeletedPostId; - const isUserSource = isSourceUserSource(post.source); const { highlightBookmarkedPost } = useBookmarkProvider({ bookmarked: (post.bookmarked && !showFeedback) ?? false, }); @@ -116,22 +101,7 @@ export const PostCardHeader = ({ highlightBookmarkedPost && headerHiddenClassName, )} > - {!isUserSource && } - {!!post?.author && ( - - } - > - - - )} + {children} - import('../entity/UserEntityCard'), -); - -const HoverCard = dynamic( - /* webpackChunkName: "hoverCard" */ () => import('./HoverCard'), -); +import { AuthorSourceStack } from './AuthorSourceStack'; +// enableSourceHeader is kept for API compatibility with the shared card props; +// the source now always renders in the stack (when it isn't a user source). type SquadPostCardHeaderProps = { post: Post; enableSourceHeader?: boolean }; export const SquadPostCardHeader = ({ post, - enableSourceHeader = false, }: SquadPostCardHeaderProps): ReactElement => { const { author, source, bookmarked } = post; const { highlightBookmarkedPost } = useBookmarkProvider({ bookmarked: bookmarked ?? false, }); - const isUserSource = isSourceUserSource(post.source); return ( <> @@ -46,36 +31,8 @@ export const SquadPostCardHeader = ({ highlightBookmarkedPost && headerHiddenClassName, )} > -
- {!isUserSource && ( - - )} - {author && ( - - } - > - - - )} +
+
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/cards/common/list/PostCardHeader.tsx b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx index f5da75be75e..3c630287da3 100644 --- a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx @@ -1,7 +1,6 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; -import dynamic from 'next/dynamic'; import { CardHeader } from './ListCard'; import { ReadArticleButton } from '../ReadArticleButton'; import { getGroupedHoverContainer } from '../common'; @@ -14,20 +13,8 @@ import PostMetadata from './PostMetadata'; import { OpenLinkIcon } from '../../../icons'; import { useReadPostButtonText } from './hooks'; import { BookmakProviderHeader } from './BookmarkProviderHeader'; -import { ProfileImageSize } from '../../../ProfilePicture'; -import { ProfileImageLink } from '../../../profile/ProfileImageLink'; -import type { UserShortProfile } from '../../../../lib/user'; import { PostOptionButton } from '../../../../features/posts/PostOptionButton'; -import { isSourceUserSource } from '../../../../graphql/sources'; - -const HoverCard = dynamic( - /* webpackChunkName: "hoverCard" */ () => import('../HoverCard'), -); - -const UserEntityCard = dynamic( - /* webpackChunkName: "userEntityCard" */ () => - import('../../entity/UserEntityCard'), -); +import { AuthorSourceStack } from '../AuthorSourceStack'; interface CardHeaderProps { post: Post; @@ -71,7 +58,6 @@ export const PostCardHeader = ({ }); const isCollectionType = post.type === 'collection'; - const isUserSource = isSourceUserSource(post.source); const showCTA = !isFeedPreview && ([PostType.Article, PostType.VideoYouTube].includes(post.type) || @@ -82,26 +68,16 @@ export const PostCardHeader = ({ <> {highlightBookmarkedPost && } - {children} - {!!post?.author && ( - - } - > - - + {post?.author && !isCollectionType ? ( + // Author present (freeform/share/squad): show the author + source as + // an overlapping stack — author in front on the left, source behind + // on the right — expanded on mobile, matching the collection source + // stack. Collection cards keep their own source-stack children (even + // if a collection ever has an author), and article cards keep their + // single source. + + ) : ( + children )} { + const { data: source } = useQuery({ + queryKey: generateQueryKey(RequestKey.Source, undefined, id, 'tooltip'), + queryFn: () => getSourceTooltip(id), + enabled: !!id, + staleTime: StaleTime.Default, + }); + + if (!source) { + return null; + } + + return ; +}; diff --git a/packages/shared/src/components/cards/entity/LazyUserEntityCard.tsx b/packages/shared/src/components/cards/entity/LazyUserEntityCard.tsx new file mode 100644 index 00000000000..9b7d055fd83 --- /dev/null +++ b/packages/shared/src/components/cards/entity/LazyUserEntityCard.tsx @@ -0,0 +1,34 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getUserShortInfo } from '../../../graphql/users'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import UserEntityCard from './UserEntityCard'; + +interface LazyUserEntityCardProps { + id: string; +} + +/** + * Fetches the author's profile on demand (when the hover card mounts) so the + * feed request stays lean — instead of shipping bio/reputation/… on every post. + */ +export const LazyUserEntityCard = ({ + id, +}: LazyUserEntityCardProps): ReactElement | null => { + const { data: user } = useQuery({ + queryKey: generateQueryKey( + RequestKey.UserShortById, + id ? { id } : undefined, + ), + queryFn: () => getUserShortInfo(id), + enabled: !!id, + staleTime: StaleTime.Default, + }); + + if (!user) { + return null; + } + + return ; +}; 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 00000000000..8760cb03bbe --- /dev/null +++ b/packages/shared/src/components/post/collection/CollectionSourceStack.tsx @@ -0,0 +1,168 @@ +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'), +); + +// Lazy: the source's details are fetched on hover, so the feed stays lean. +const LazySourceEntityCard = dynamic( + /* webpackChunkName: "lazySourceEntityCard" */ () => + import('../../cards/entity/LazySourceEntityCard').then( + (m) => m.LazySourceEntityCard, + ), +); + +// 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); + + // Squads read from their handle; every other source lazily fetches its + // details by id when the hover card mounts. + const renderEntityCard = (source: SourceTooltip): ReactElement | null => { + if (source.type === SourceType.Squad) { + return ; + } + + if (!source.id) { + return null; + } + + return ; + }; + + const withHoverCard = ( + source: SourceTooltip, + avatar: ReactElement, + ): ReactElement => { + if (!enableHoverCard) { + return {avatar}; + } + + return ( + + {renderEntityCard(source)} + + ); + }; + + 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 9fbff7cb170..1f3f7b33552 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -658,7 +658,10 @@ export const FEED_POST_FRAGMENT = gql` } author { id + name + image username + permalink } slug clickbaitTitleDetected @@ -670,8 +673,15 @@ export const FEED_POST_FRAGMENT = gql` trending feedMeta collectionSources { + # Only the fields the stacked avatars render. The hover card's rich data + # (description, followers, upvotes) is fetched lazily on hover via + # getSourceTooltip, so the feed request stays lean. + id handle + name image + permalink + type } numCollectionSources updatedAt diff --git a/packages/shared/src/graphql/sources.ts b/packages/shared/src/graphql/sources.ts index ed04ed9cefa..1214ab23f5d 100644 --- a/packages/shared/src/graphql/sources.ts +++ b/packages/shared/src/graphql/sources.ts @@ -139,6 +139,31 @@ export const SOURCE_QUERY = gql` ${SOURCE_DIRECTORY_INFO_FRAGMENT} `; +// Fetched lazily when a source hover card opens, so the feed stays lean. +// membersCount/flags resolve here (unlike in the feed's collectionSources, +// where the membersCount resolver throws for Machine sources). +export const SOURCE_TOOLTIP_QUERY = gql` + query SourceTooltip($id: ID!) { + source(id: $id) { + ...SourceDirectoryInfo + type + membersCount + flags { + totalUpvotes + } + } + } + ${SOURCE_DIRECTORY_INFO_FRAGMENT} +`; + +export const getSourceTooltip = async ( + id: string, +): Promise => { + const res = await gqlClient.request(SOURCE_TOOLTIP_QUERY, { id }); + + return (res.source as SourceTooltip) ?? null; +}; + export const SOURCE_DIRECTORY_QUERY = gql` query SourceDirectory { trendingSources { diff --git a/packages/storybook/stories/components/cards/CollectionTrendCardTags.stories.tsx b/packages/storybook/stories/components/cards/CollectionTrendCardTags.stories.tsx new file mode 100644 index 00000000000..3cdebd20f53 --- /dev/null +++ b/packages/storybook/stories/components/cards/CollectionTrendCardTags.stories.tsx @@ -0,0 +1,244 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { fn } from 'storybook/test'; +import { PostType, UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import { CollectionGrid } from '@dailydotdev/shared/src/components/cards/collection/CollectionGrid'; +import { FreeformGrid } from '@dailydotdev/shared/src/components/cards/Freeform/FreeformGrid'; + +import ExtensionProviders from '../../extension/_providers'; + +const source = { + id: 'tds', + handle: 'tds', + name: 'Towards Data Science', + permalink: 'https://app.daily.dev/sources/tds', + image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/tds', + type: SourceType.Machine, + active: true, + description: 'Your home for data science and AI.', + membersCount: 132000, + flags: { totalUpvotes: 48000 }, +}; + +const collectionSources = [ + { + id: 'react', + handle: 'reactjs', + name: 'React', + permalink: 'https://app.daily.dev/sources/reactjs', + image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/react', + type: SourceType.Machine, + }, + { + id: 'nextjs', + handle: 'nextjs', + name: 'Next.js', + permalink: 'https://app.daily.dev/sources/nextjs', + image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/nextjs', + type: SourceType.Machine, + }, + { + id: 'ts', + handle: 'typescript', + name: 'TypeScript', + permalink: 'https://app.daily.dev/sources/typescript', + image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/ts', + type: SourceType.Machine, + }, +]; + +const image = + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/scvgbr5cbutrjhi1e8ao'; + +const tags = ['react', 'javascript', 'frontend']; + +const base = { + numUpvotes: 42, + numComments: 12, + bookmarked: false, + read: false, + upvoted: false, + commented: false, + source, + tags, + readTime: 7, + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2026-07-16T09:00:00.000Z', + permalink: 'https://api.daily.dev/r/post-1', + commentsPermalink: 'https://daily.dev/posts/post-1', + userState: { vote: UserVote.None, flags: { feedbackDismiss: false } }, +}; + +const make = (overrides: Record): Post => + ({ ...base, ...overrides } as unknown as Post); + +// Titles chosen to wrap to 1, 2 and 3 lines at the ~320px grid card width. +const TITLES: { label: string; title: string }[] = [ + { label: '1-line title', title: 'The WASM revolution' }, + { + label: '2-line title', + title: 'The best React libraries we shipped this year', + }, + { + label: '3-line title', + title: + 'Grok AI uploads entire git repository, including history and user directories', + }, +]; + +const collectionImage = (title: string, i: number): Post => + make({ + id: `coll-image-${i}`, + type: PostType.Collection, + title, + image, + collectionSources, + numCollectionSources: 7, + }); + +const collectionFreeform = (title: string, i: number): Post => + make({ + id: `coll-freeform-${i}`, + type: PostType.Collection, + title, + contentHtml: + '

A hand-picked round-up of what the community shipped this year, ' + + 'distilled into one place. Two competing philosophies about how teams ' + + 'build and ship are colliding, and the gap between them is getting ' + + 'harder to ignore for anyone paying attention.

', + collectionSources, + numCollectionSources: 10, + }); + +const article = (title: string, i: number): Post => + make({ id: `article-${i}`, type: PostType.Article, title, image }); + +// A full author so the profile hover card (UserEntityCard) renders in +// Storybook — it needs id, username, name, image, permalink and createdAt. +const author = { + id: 'author-1', + name: 'John Developer', + username: 'johndeveloper', + image: + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/scvgbr5cbutrjhi1e8ao', + permalink: 'https://app.daily.dev/johndeveloper', + bio: 'Full-stack developer who loves shipping fast and writing about it.', + createdAt: '2022-03-10T10:00:00.000Z', + reputation: 4200, + isPlus: false, +}; + +// A regular freeform (share) post — NOT a collection. Rendered by the default +// FreeformGrid. The author (primary) sits in front on the left and the source +// (secondary) tucks in behind on the right. Uses the inline machine source so +// the source hover card renders in Storybook; real squad posts fetch theirs. +const freeform = (title: string, i: number): Post => + make({ + id: `freeform-${i}`, + type: PostType.Freeform, + author, + title, + contentHtml: + '

A hand-picked round-up of what the community shipped this year, ' + + 'distilled into one place. Two competing philosophies about how teams ' + + 'build and ship are colliding, and the gap between them is getting ' + + 'harder to ignore for anyone paying attention.

', + }); + +const handlers = { + onPostClick: fn(), + onPostAuxClick: fn(), + onUpvoteClick: fn(), + onDownvoteClick: fn(), + onCommentClick: fn(), + onBookmarkClick: fn(), + onCopyLinkClick: fn(), + onShare: fn(), + onReadArticleClick: fn(), +}; + +const gridStyle = { + maxWidth: 'calc(20rem * 4 + 2rem * 3)', +} as React.CSSProperties; + +const Row = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}): React.ReactElement => ( + <> +

{label}

+
+ {children} +
+ +); + +const ColLabel = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const CollectionTrendCardTags = (): React.ReactElement => ( + +
+

+ Collection / Trend card — alignment across all three cards +

+

+ Each row shows the cards side by side with the{' '} + same title, at 1, 2 and 3 lines: freeform collection, + collection with image, the default article card, and a freeform post. + Check that the tag chips and date strip line up across all of them, that + the text preview sits a comfortable gap below the date, and hover the + header avatars — author shows the profile card, source shows the source + card. +

+ + Collection (freeform text) + Collection (with image) + Default article (image) + Freeform post (not a collection) + + + {TITLES.map((t, i) => ( + + + + + + + ))} +
+
+); + +const meta: Meta = { + title: 'Components/Cards/Collection Trend Card Tags', + component: CollectionTrendCardTags, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const TagPlacement: Story = { + render: () => , + name: 'Alignment matrix', +};