diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx index e2b1c53a60c..d6feb938769 100644 --- a/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx +++ b/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx @@ -134,6 +134,27 @@ it('should display the collection source stack in the header', async () => { await screen.findByAltText('Avatar of src2'); }); +it('should link each source in the stack to its source page', async () => { + renderComponent({ + post: { + ...post, + collectionSources: [ + { + id: 'src1', + handle: 'src1', + name: 'Source One', + image: 'https://daily.dev/src1.png', + permalink: 'https://daily.dev/sources/src1', + }, + ], + numCollectionSources: 1, + } as Post, + }); + const link = await screen.findByLabelText('Go to Source One'); + expect(link).toHaveAttribute('href', 'https://daily.dev/sources/src1'); + expect(link).toContainElement(screen.getByAltText('Avatar of src1')); +}); + it('should show options button on hover when in laptop size', async () => { renderComponent(); const header = await screen.findByLabelText('Options'); diff --git a/packages/shared/src/components/post/collection/CollectionPillSources.tsx b/packages/shared/src/components/post/collection/CollectionPillSources.tsx index 9e8d8ad5132..563dde3ce6a 100644 --- a/packages/shared/src/components/post/collection/CollectionPillSources.tsx +++ b/packages/shared/src/components/post/collection/CollectionPillSources.tsx @@ -2,8 +2,8 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { Pill } from '../../Pill'; -import type { SourceAvatarProps } from '../../profile/source'; -import { SourceAvatar } from '../../profile/source'; +import type { LinkableSource } from '../../profile/source/SourceAvatarLink'; +import { SourceAvatarLink } from '../../profile/source/SourceAvatarLink'; import { ProfilePictureGroup } from '../../ProfilePictureGroup'; import { ProfileImageSize } from '../../ProfilePicture'; @@ -12,7 +12,7 @@ interface CollectionPillSourcesProps { main?: string; avatar?: string; }; - sources: SourceAvatarProps['source'][]; + sources: LinkableSource[]; alwaysShowSources?: boolean; totalSources: number; size?: ProfileImageSize; @@ -49,8 +49,8 @@ export const CollectionPillSources = ({ limit={limit} > {sources.map((source) => ( - withHoverCard( source, -
- -
, + avatarClassName="!mr-0 box-content ring-2 ring-background-default" + source={source} + size={size} + />, ), )} {remaining > 0 && ( diff --git a/packages/shared/src/components/profile/source/SourceAvatarLink.tsx b/packages/shared/src/components/profile/source/SourceAvatarLink.tsx new file mode 100644 index 00000000000..efe80980ae2 --- /dev/null +++ b/packages/shared/src/components/profile/source/SourceAvatarLink.tsx @@ -0,0 +1,67 @@ +import type { HTMLAttributes, ReactElement, Ref } from 'react'; +import React, { forwardRef } from 'react'; +import classNames from 'classnames'; +import Link from '../../utilities/Link'; +import { SourceAvatar } from './SourceAvatar'; +import type { Source } from '../../../graphql/sources'; +import { ProfileImageSize } from '../../ProfilePicture'; + +export type LinkableSource = Pick & + Partial>; + +export interface SourceAvatarLinkProps extends HTMLAttributes { + source: LinkableSource; + size?: ProfileImageSize; + avatarClassName?: string; +} + +/** + * A source avatar that navigates to the source (or squad) page, matching the + * behavior of the single source avatar on article cards. Falls back to a bare + * avatar when the query that produced the source did not select `permalink`. + */ +function SourceAvatarLinkComponent( + { + source, + size = ProfileImageSize.Medium, + className, + avatarClassName, + ...props + }: SourceAvatarLinkProps, + ref?: Ref, +): ReactElement { + const avatar = ( + + ); + + // No permalink means nothing to link to, but the ref still has to reach a DOM + // node: Radix's hover card trigger composes one onto this component and never + // opens without it. A span (not an href-less anchor) also keeps the click + // falling through to the card link, since `.card a` gets pointer-events: all. + if (!source?.permalink) { + return ( + } + className={classNames('flex', className)} + > + {avatar} + + ); + } + + return ( + + + {avatar} + + + ); +} + +export const SourceAvatarLink = forwardRef(SourceAvatarLinkComponent); diff --git a/packages/shared/src/features/profile/common.ts b/packages/shared/src/features/profile/common.ts index 12bed6510d5..c44521080d0 100644 --- a/packages/shared/src/features/profile/common.ts +++ b/packages/shared/src/features/profile/common.ts @@ -1,3 +1,12 @@ +/** + * The profile page wraps every section in `p-6`, so a horizontally scrolling + * strip clips 24px short of the page edge — the content looks abruptly cut off + * instead of continuing off-screen. Cancel that padding on the scroll container + * and re-apply it inside, so the strip clips at the card edge while its items + * stay aligned with the section heading. + */ +export const profileStripBleed = '-mx-6 px-6'; + export const profileSecondaryFieldStyles = { outerLabel: '!px-0 !typo-callout', baseField: '!h-12', diff --git a/packages/shared/src/features/profile/components/Activity.helpers.tsx b/packages/shared/src/features/profile/components/Activity.helpers.tsx index a5678a93a3c..8eecd9ba726 100644 --- a/packages/shared/src/features/profile/components/Activity.helpers.tsx +++ b/packages/shared/src/features/profile/components/Activity.helpers.tsx @@ -67,8 +67,14 @@ export const COMMENT_CLASS_NAME = { } as const; export const MIN_ITEMS_FOR_SHOW_MORE = 3; +// The bleed classes are the `profileStripBleed` pair scoped to the feed's own +// scroll container, so the strip clips at the card edge rather than 24px short +// of it. `scroll-px-6` keeps snapped cards aligned with the section heading. +// They match on `.grid.snap-x` (only FeedContainer's horizontal scroller has +// both) rather than plain `.grid`: a bare `grid` class also appears inside list +// cards, and margins there would visibly break them. export const HORIZONTAL_FEED_CLASSES = - '[&_.grid]:!auto-cols-[17rem] tablet:[&_.grid]:!auto-cols-[20rem] [&_.grid]:gap-4'; + '[&_.grid]:!auto-cols-[17rem] tablet:[&_.grid]:!auto-cols-[20rem] [&_.grid]:gap-4 [&_.grid.snap-x]:-mx-6 [&_.grid.snap-x]:px-6 [&_.grid.snap-x]:scroll-px-6'; export const TAB_ITEMS = activityTabs.map((tab) => ({ label: tab.title })); export const ACTIVITY_QUERY_KEYS = { diff --git a/packages/shared/src/features/profile/components/Activity.tsx b/packages/shared/src/features/profile/components/Activity.tsx index a3f6b7c1e53..d77ce21a82d 100644 --- a/packages/shared/src/features/profile/components/Activity.tsx +++ b/packages/shared/src/features/profile/components/Activity.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useContext, useState, useMemo, useCallback } from 'react'; +import classNames from 'classnames'; import type { PublicProfile } from '../../../lib/user'; import AuthContext from '../../../contexts/AuthContext'; import { ActivityTabIndex, activityTabs } from './Activity.helpers'; @@ -71,7 +72,17 @@ export const Activity = ({ user }: ActivityProps): ReactElement | null => { } return ( -
+
{renderContent()}
); diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx index 2db7735133e..6f21e091ce7 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx @@ -23,6 +23,7 @@ import { useConditionalFeature } from '../../../../hooks/useConditionalFeature'; import { achievementTrackingWidgetFeature } from '../../../../lib/featureManagement'; import { useProfileAchievements } from '../../../../hooks/profile/useProfileAchievements'; import { shouldShowAchievementTracker } from '../../../../lib/achievements'; +import { profileStripBleed } from '../../common'; const BadgesAndAwards = dynamic(() => import('./BadgesAndAwards').then((mod) => mod.BadgesAndAwards), @@ -110,7 +111,12 @@ export function ProfileWidgets({ return (
diff --git a/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx index 60c425ef477..a1de1d61c7e 100644 --- a/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx +++ b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx @@ -27,6 +27,7 @@ import { AchievementCard } from './AchievementCard'; import { RaritySparkles } from './RaritySparkles'; import { useLazyModal } from '../../../../hooks/useLazyModal'; import { LazyModal } from '../../../../components/modals/common/types'; +import { profileStripBleed } from '../../common'; interface ProfileAchievementShowcaseProps { user: PublicProfile; @@ -66,7 +67,7 @@ export function ProfileAchievementShowcase({ } return ( -
+
{hasShowcase ? ( -
+ // -my-2/py-2 keeps the rarity glow and sparkles, which deliberately + // escape each tile, from being clipped by the scroll container +
{showcaseAchievements.map((userAchievement) => { const { achievement } = userAchievement; const rarityTier = getAchievementRarityTier(achievement.rarity); diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index e5e9b0858c5..e3f8ddb3e60 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -415,6 +415,8 @@ export const POST_BY_ID_QUERY = gql` collectionSources { handle image + name + permalink } } relatedCollectionPosts: relatedPosts( @@ -502,6 +504,8 @@ export const POST_BY_ID_STATIC_FIELDS_QUERY = gql` collectionSources { handle image + name + permalink } sharedPost { ...SharedPostInfo