diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index de70d8284a..0fd5c11f5d 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -1,27 +1,23 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef, useState } from 'react'; +import React from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { UserVote } from '../../../graphql/posts'; -import { useViewSize, useVotePost, ViewSize } from '../../../hooks'; +import { useVotePost } from '../../../hooks'; import { useBookmarkPost } from '../../../hooks/useBookmarkPost'; import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; import { useCanAwardUser } from '../../../hooks/useCoresFeature'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; -import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant'; import { useAuthContext } from '../../../contexts/AuthContext'; import type { PostOrigin } from '../../../hooks/log/useLogContextData'; import { Origin } from '../../../lib/log'; import { AuthTriggers } from '../../../lib/auth'; -import { ButtonSize } from '../../buttons/Button'; import { ButtonColor } from '../../buttons/ButtonV2'; import { CardAction } from '../../buttons/CardAction'; -import { BookmarkButton } from '../../buttons/BookmarkButton'; -import CloseButton from '../../CloseButton'; import { UpvoteButtonIcon } from '../../cards/common/UpvoteButtonIcon'; -import { IconSize } from '../../Icon'; import { + BookmarkIcon, DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, @@ -30,34 +26,29 @@ import { import { Tooltip } from '../../tooltip/Tooltip'; import type { LoggedUser } from '../../../lib/user'; import { PostClickbaitShield } from '../common/PostClickbaitShield'; -import { PostMenuOptions } from '../PostMenuOptions'; interface FocusCardActionBarProps { post: Post; origin?: PostOrigin; onComment?: () => void; onCopyLinkClick?: (post?: Post) => void; - /** When provided (post modal), renders an X close button next to the menu. */ - onClose?: () => void; className?: string; } /** - * Engagement bar for the redesign focus card, built on the CardAction - * primitives (PR #6064 guideline): each action's count lives inside the click - * target so the icon or number performs the action. Sticks to the top while - * scrolling; the modal X appears only once the bar is pinned. + * Engagement bar for the redesign focus card: one full-width floating glass bar + * (translucent, blurred, soft shadow) with post-contribution actions on the + * left and utility actions on the right. Floats pinned to the BOTTOM of the + * scroll area on tablet up (never the top). */ export const FocusCardActionBar = ({ post, origin = Origin.ArticlePage, onComment, onCopyLinkClick, - onClose, className, }: FocusCardActionBarProps): ReactElement => { const { user, showLogin } = useAuthContext(); - const { isV2 } = useLayoutVariant(); const { toggleUpvote, toggleDownvote } = useVotePost(); const { toggleBookmark } = useBookmarkPost(); const { onShowPanel, onClose: onCloseBlockPanel } = useBlockPostPanel(post); @@ -67,106 +58,9 @@ export const FocusCardActionBar = ({ receivingUser: post.author as LoggedUser | undefined, }); - // Track whether the bar is pinned, and at which edge. The sentinel sits just - // above the bar: when it scrolls above the viewport top the bar is pinned at - // the TOP; when it's still below the viewport the bar is floating at the - // BOTTOM. The modal's X is only useful at the top (where the top nav strip - // has scrolled away) — at the bottom that strip is still on screen. - const sentinelRef = useRef(null); - const barRef = useRef(null); - const copyLinkRef = useRef(null); - const [isStuck, setIsStuck] = useState(false); - const [isStuckTop, setIsStuckTop] = useState(false); - useEffect(() => { - const el = sentinelRef.current; - if (!el || typeof IntersectionObserver === 'undefined') { - return undefined; - } - const observer = new IntersectionObserver( - ([entry]) => { - const stuck = !entry.isIntersecting; - setIsStuck(stuck); - const rootTop = entry.rootBounds?.top ?? 0; - setIsStuckTop(stuck && entry.boundingClientRect.top < rootTop); - }, - { threshold: 0 }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, []); - const isUpvoteActive = post?.userState?.vote === UserVote.Up; const isDownvoteActive = post?.userState?.vote === UserVote.Down; const isAwarded = !!post?.userState?.awarded; - // Counts are hidden in the resting bar (the stats row sitting right above it - // already shows them) and surface only once the bar is pinned and that row - // has scrolled away. - const upvotes = post.numUpvotes || 0; - const comments = post.numComments || 0; - const awards = post.numAwards || 0; - // The bar floats (sticky) from tablet up, so surface the metrics + menu - // whenever it's actually pinned there — including when a long post floats it - // at the bottom on load, where the stats row above has scrolled off. Below - // tablet the bar is plain in-flow, so keep it stable (no counts) — that's the - // width where toggling on scroll looked like flicker. - const barFloats = useViewSize(ViewSize.Tablet); - const isPinned = isStuck && barFloats; - // The X (modal close) only makes sense when pinned at the top; at the bottom - // the modal's top strip — and its own close — are still on screen. - const isPinnedTop = isStuckTop && barFloats; - // Sticky at BOTH edges (`top` + `bottom`), tablet and up only — on mobile the - // dedicated floating bottom bar already covers this, so the desktop treatment - // is excluded there. While its natural spot is still below the fold the bar - // pins near the bottom (always reachable), scrolls naturally through the - // viewport, then pins near the top once it scrolls above. `top-4`/`bottom-4` - // leave a gap from each edge so the pill reads as floating. The top offset - // also accounts for the top chrome — the modal has no app header; on the post - // page the v2 rail hides the global header on laptop for logged-in users, so - // the bar floats near the top, while the legacy/logged-out layout must clear - // a fixed 4rem header (4rem + 1rem gap = top-20). `onClose` is modal-only. - const railOwnsHeader = isV2 && !!user; - const stickyOffsetClassName = - onClose || railOwnsHeader - ? 'tablet:top-4 tablet:bottom-4' - : 'tablet:top-4 tablet:bottom-4 laptop:top-20'; - - // Fold copy link out of the row when the bar would overflow, and bring it - // back inline when there is room again. Measured against the real available - // width — not breakpoints — so it reacts to page/modal resizing. - useEffect(() => { - const bar = barRef.current; - if (!bar) { - return undefined; - } - const fit = () => { - const copyLink = copyLinkRef.current; - // Show first (inline display overrides the SSR fallback classes), then - // hide it if the row still overflows. - if (copyLink) { - copyLink.style.display = 'flex'; - } - const overflows = () => bar.scrollWidth > bar.clientWidth; - if (copyLink && overflows()) { - copyLink.style.display = 'none'; - } - }; - fit(); - if (typeof ResizeObserver === 'undefined') { - return undefined; - } - const observer = new ResizeObserver(fit); - observer.observe(bar); - return () => observer.disconnect(); - // isPinned/counts change the row width (counts + "…" menu appear when pinned). - }, [ - canAward, - post.clickbaitTitleDetected, - post.bookmarked, - isPinned, - upvotes, - comments, - awards, - ]); const onToggleUpvote = async () => { if (post?.userState?.vote === UserVote.None) { @@ -211,117 +105,91 @@ export const FocusCardActionBar = ({ }; return ( - <> -
-
-
- - } - iconPressed={} - count={isPinned ? upvotes : undefined} - pressed={isUpvoteActive} - onClick={onToggleUpvote} - /> - - - } - iconPressed={} - pressed={isDownvoteActive} - onClick={onToggleDownvote} - /> - - +
+
+ + } + iconPressed={} + pressed={isUpvoteActive} + onClick={onToggleUpvote} + /> + + + } + iconPressed={} + pressed={isDownvoteActive} + onClick={onToggleDownvote} + /> + + + } + iconPressed={} + pressed={post.commented} + onClick={onComment} + /> + + {canAward && ( + } - iconPressed={} - count={isPinned ? comments : undefined} - pressed={post.commented} - onClick={onComment} + id="award-post-btn" + label="Award" + color={ButtonColor.Cabbage} + icon={} + iconPressed={} + pressed={isAwarded} + onClick={onGiveAward} /> - {canAward && ( - - } - iconPressed={} - count={isPinned ? awards : undefined} - pressed={isAwarded} - onClick={onGiveAward} - /> - - )} -
+ )} +
-
- + + } + iconPressed={} + pressed={post.bookmarked} + onClick={onToggleBookmark} /> - {/* Bookmark stays — it is the primary save action. Copy link folds - out when space is tight (see the overflow effect); the - `hidden tablet:flex` classes are only the pre-measurement (SSR) - fallback — the effect overrides display once it measures. The "…" - menu and analytics now live in the card header / stats row. */} -
- - } - onClick={() => onCopyLinkClick?.(post)} - /> - -
- {post.clickbaitTitleDetected && ( - - )} - {/* While pinned, the article header (which owns the "…" menu) has - scrolled away, so surface the menu here — to the left of the X. */} - {isPinned && ( - - )} - {isPinnedTop && onClose && ( - onClose()} /> - )} -
+
+ + } + onClick={() => onCopyLinkClick?.(post)} + /> + + {post.clickbaitTitleDetected && ( + + )}
- +
); }; diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 9180f39988..af8c3826c5 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -5,7 +5,6 @@ import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { getReadArticleHref, - getReadPostButtonText, isInternalReadType, isVideoPost, PostType, @@ -17,8 +16,8 @@ import usePostContent from '../../../hooks/usePostContent'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery'; import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromptGate'; -import { useReaderModalEligibility } from '../reader/hooks/useReaderModalEligibility'; -import { EarthIcon } from '../../icons'; +import { OpenLinkIcon } from '../../icons'; +import { IconSize } from '../../Icon'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; import { getImageOriginRect } from '../../modals/ImageModal'; @@ -28,11 +27,9 @@ import Markdown from '../../Markdown'; import { ContentEmbeds } from '../../contentEmbeds/ContentEmbeds'; import { LazyImage } from '../../LazyImage'; import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; -import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; -import { getReadPostButtonIcon } from '../../cards/common/ReadArticleButton'; +import { ButtonSize, ButtonVariant } from '../../buttons/Button'; import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; import { PostTagList } from '../tags/PostTagList'; -import { TruncateText } from '../../utilities'; import { combinedClicks } from '../../../lib/click'; import { useFeature } from '../../GrowthBookProvider'; import { feature } from '../../../lib/featureManagement'; @@ -47,6 +44,8 @@ import { FollowButton } from '../../contentPreference/FollowButton'; import { ContentPreferenceType } from '../../../graphql/contentPreference'; import { PostSidebarAdWidget } from '../PostSidebarAdWidget'; import { PostMenuOptions } from '../PostMenuOptions'; +import { BoostPostButton } from '../../../features/boost/BoostButton'; +import { useShowBoostButton } from '../../../features/boost/useShowBoostButton'; import { FocusCardActionBar } from './FocusCardActionBar'; import { PostDiscussionPanel } from './PostDiscussionPanel'; import { CollectionSources } from './CollectionSources'; @@ -234,13 +233,19 @@ export const PostFocusCard = ({ : undefined; const isVideoType = isVideoPost(article); const { title } = useSmartTitle(article); + // A share post carries the user's own commentary in `post.title` (separate + // from the shared article's title). Surface it so the text the user actually + // wrote isn't dropped; skip it when it just mirrors the article's title. + const commentary = + isShared && post.title && post.title !== article.title ? post.title : null; const { onCopyPostLink, onReadArticle } = usePostContent({ origin, post }); const { openModal } = useLazyModal(); const { onShowUpvoted } = useUpvoteQuery(); + // Boost CTA for eligible authors — lived in PostHeaderActions, which the + // redesign card doesn't use, so surface it here in the header row. + const showBoostButton = useShowBoostButton({ post }); const { onReadClick: onReaderInstallGateClick } = useReaderInstallPromptGate(post); - const { isReaderEnabled } = useReaderModalEligibility(); - const isReaderVariant = isReaderEnabled && post.type === PostType.Article; const showCodeSnippets = useFeature(feature.showCodeSnippets); const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); @@ -252,6 +257,7 @@ export const PostFocusCard = ({ const videoWrapperRef = useRef(null); const [isVideoExpanded, setIsVideoExpanded] = useState(false); const readHref = getReadArticleHref(post); + const canReadArticle = !!readHref && !isInternalReadType(post); useEffect(() => { if (!isVideoType || isVideoExpanded) { @@ -291,25 +297,28 @@ export const PostFocusCard = ({ focusCommentRef.current(); }; - // Rendered in the title column, directly under the title, so it stays close - // to the title regardless of the cover image height. The engagement bar lives - // further down by the comment composer where the reader's cursor rests. - const renderReadButton = (className: string): ReactElement | null => - readHref && !isInternalReadType(post) ? ( - - ) : null; + // The read CTA sits above the TL;DR on mobile and after it on desktop, so it + // is defined once and placed twice behind breakpoint-gated wrappers. + const readCta = canReadArticle ? ( + + Read the full article + + + ) : null; return (
{author ? (
- null} - className={{ - container: 'min-w-0 !p-0 hover:bg-transparent', - textWrapper: 'min-w-0', - }} - /> + + null} + className={{ + container: + 'min-w-0 cursor-pointer !p-0 hover:bg-transparent', + textWrapper: 'min-w-0', + }} + /> + ) )} -
- +
+ {showBoostButton && ( + + )} +
+ +
@@ -403,11 +425,17 @@ export const PostFocusCard = ({ {!isShared && isCollection && (

Collection

)} - {/* Title and image are top-aligned columns. The cover image opens a - lightbox rather than navigating away. The read button lives in - the title column (right under the title) so it hugs the title - regardless of the image height — a short title next to a tall - image keeps the button close instead of dragging it down. */} + {/* The sharer's own words, above the shared article they reference. */} + {commentary && ( +

+ {commentary} +

+ )} + {/* Title column and cover image sit side by side, top-aligned. The + image keeps a fixed ratio (square on mobile/small tablet, the + wide open-graph cover ratio from tablet up) so a short title + can't squash it. The cover opens a lightbox rather than + navigating away. */}

- {title} + {/* The title links to the post and turns the link colour on + hover; the read button below is the other entry point. */} + {canReadArticle ? ( + + {title} + + ) : ( + title + )}

- {renderReadButton('w-fit')} + {/* Directly under the title so a short title pulls the strip + up (rather than sitting below a taller cover image). */} + 0 && ( + + From{' '} + + {article.domain} + + + ) + } + isVideoType={isVideoType} + readTime={article.readTime} + />
{!isVideoType && article.image && (
- 0 && ( - - From{' '} - - {article.domain} - - - ) - } - isVideoType={isVideoType} - readTime={article.readTime} - /> - {isVideoType && (
)} + {/* Mobile: the read CTA sits above the TL;DR. */} + {readCta &&
{readCta}
} + {article.contentHtml ? ( <> @@ -524,6 +572,9 @@ export const PostFocusCard = ({ )) )} + {/* Desktop: the read CTA sits after the summary. */} + {readCta &&
{readCta}
} +
diff --git a/packages/shared/src/components/post/reader/SourceStrip.tsx b/packages/shared/src/components/post/reader/SourceStrip.tsx index cbc4ceb57f..fd3600cf53 100644 --- a/packages/shared/src/components/post/reader/SourceStrip.tsx +++ b/packages/shared/src/components/post/reader/SourceStrip.tsx @@ -62,7 +62,7 @@ export function SourceStrip({ trigger={
@@ -92,7 +92,7 @@ export function SourceStrip({ compact ? TypographyType.Callout : TypographyType.Subhead } color={TypographyColor.Primary} - className="truncate group-hover:underline group-focus-visible:underline" + className="truncate hover:underline focus-visible:underline" title={source.name} bold > diff --git a/packages/storybook/stories/components/PostReadCta.stories.tsx b/packages/storybook/stories/components/PostReadCta.stories.tsx new file mode 100644 index 0000000000..de46c2f79d --- /dev/null +++ b/packages/storybook/stories/components/PostReadCta.stories.tsx @@ -0,0 +1,587 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement } from 'react'; +import React, { useRef } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonVariant, + ButtonSize, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyType, + TypographyColor, + TypographyTag, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { OpenLinkIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; + +/** + * Design-review playground (not shipping UI). The chosen direction — a list row + * led by a square, primary open-link tile — across three stories: + * • All variants — interface-feel motion (jakub.kr spirit). M1 + M2 ship. + * • Prominent — the same expression turned up. + * • M2 variations — ten takes on the springy-tile-pop row, no source name. + * Shown right after the last line of the TL;DR. + */ + +const DOMAIN = 'pragmaticengineer.com'; + +const EASE_SNAP = 'ease-[cubic-bezier(0.2,0.7,0.2,1)]'; +const EASE_SPRING = 'ease-[cubic-bezier(0.34,1.56,0.64,1)]'; + +const nudge = classNames( + 'transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 motion-reduce:transition-none', + EASE_SNAP, +); + +// Decorative primary tile (a span, not a button) — the row itself is the link. +// Built-in springy pop (M2) + icon lift-off (M1) on group hover. +const SpanTile = ({ + sizeClass = 'size-12', + iconSize = IconSize.Large, +}: { + sizeClass?: string; + iconSize?: IconSize; +}): ReactElement => ( + + + +); + +// Button-based tile for the motion story (keeps the real primary button look). +const Tile = ({ + iconClassName, + className, +}: { + iconClassName?: string; + className?: string; +}): ReactElement => ( +