Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions packages/shared/src/components/MainFeedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ import { isDevelopment, isProductionAPI, webappUrl } from '../lib/constants';
import { checkIsExtension } from '../lib/func';
import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent';
import { useLayoutVariant } from '../hooks/layout/useLayoutVariant';
import { ExploreSectionTabs } from './header/ExploreSectionTabs';
import { ExploreSortDropdown } from './header/ExploreSortDropdown';

const FeedExploreHeader = dynamic(
() =>
Expand Down Expand Up @@ -248,6 +250,7 @@ export default function MainFeedLayout({
isPopular,
isAnyExplore,
isExploreLatest,
isDiscussed,
isSortableFeed,
isCustomFeed,
isSearch: isSearchPage,
Expand Down Expand Up @@ -708,7 +711,10 @@ export default function MainFeedLayout({
// page-header strip (matching the SquadDirectoryLayout pattern). The
// inline FeedExploreComponent is suppressed below to avoid showing
// the same tabs twice.
const showExploreV2PageHeader = isAnyExplore && isV2;
// The Discussions feed (/discussed) is part of the Explore hub — show the
// same section tabs there so the hub persists. The Sort dropdown is only
// for the actual Explore sorts, so it stays gated on isAnyExplore.
const showExploreV2PageHeader = (isAnyExplore || isDiscussed) && isV2;

// v2 also hoists the regular page-header strip up here, OUTSIDE
// `FeedPageLayoutComponent`, so it can span the full floating-card
Expand Down Expand Up @@ -747,13 +753,8 @@ export default function MainFeedLayout({
<>
{showExploreV2PageHeader && (
<header className={classNames(pageHeaderClassName, '!py-0')}>
<FeedExploreHeader
directoryTabs
tab={tab}
setTab={onTabChange}
showBreadcrumbs={false}
className={{ container: 'min-w-0 flex-1' }}
/>
<ExploreSectionTabs />
{isAnyExplore && <ExploreSortDropdown />}
</header>
)}
{showFeedV2PageHeader && (
Expand Down
33 changes: 27 additions & 6 deletions packages/shared/src/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { SpotlightHost } from './spotlight/SpotlightHost';
import { FeedbackWidget } from './feedback';
import { isExtension } from '../lib/func';
import { useLayoutVariant } from '../hooks/layout/useLayoutVariant';
import { useRecordRecentPages } from '../hooks/useRecentPages';
import { isSidebarSettingsPath } from './sidebar/sidebarCategory';
import {
HomepageTopBanners,
Expand Down Expand Up @@ -105,8 +106,15 @@ function MainLayoutComponent({
const { growthbook } = useGrowthBookContext();
const { sidebarRendered } = useSidebarRendered();
const { isAvailable: isBannerAvailable } = useBanner();
const { sidebarExpanded, autoDismissNotifications, loadedSettings } =
const { sidebarExpanded, autoDismissNotifications, loadedSettings, flags } =
useContext(SettingsContext);
const isSidebarCompact = !!flags?.sidebarCompact;
const v2CollapsedPadding = isSidebarCompact
? 'tablet:pl-16 laptop:pl-16'
: 'tablet:pl-16 laptop:pl-20';
const v2ExpandedPadding = isSidebarCompact
? 'laptop:!pl-[19rem]'
: 'laptop:!pl-[20rem]';
const [hasLoggedImpression, setHasLoggedImpression] = useState(false);
const { feedName } = useActiveFeedNameContext();
const page = router?.route?.substring(1).trim() as SharedFeedPage;
Expand All @@ -120,6 +128,7 @@ function MainLayoutComponent({
const { screenCenteredOnMobileLayout } = useFeedLayout();
const { isNotificationsReady, unreadCount } = useNotificationContext();
const { isV2, isLoading: isLayoutVariantLoading } = useLayoutVariant();
useRecordRecentPages(isV2);
useNotificationParams();

// Settings pages render their navigation only inside the v2 context panel,
Expand Down Expand Up @@ -148,6 +157,18 @@ function MainLayoutComponent({
setContentTransitionsEnabled(true);
}
}, [layoutSettled]);
// The v2 page uses a tinted background; the document root stays
// `background-default`, so overscroll past the feed reveals a darker strip.
// Flag the root while v2 is active so it can paint the same tint (laptop+,
// matching where the tinted page background applies — see base.css).
useEffect(() => {
if (!isV2) {
return undefined;
}
const root = globalThis.document?.documentElement;
root?.classList.add('layout-v2');
return () => root?.classList.remove('layout-v2');
}, [isV2]);
// v2 (experiment) snaps the initial settle into place (transitions enable
// one commit later, so only genuine toggles animate). The control variant
// keeps animating on `layoutSettled` exactly as before.
Expand Down Expand Up @@ -307,14 +328,12 @@ function MainLayoutComponent({
'transition-[padding] duration-300 ease-in-out',
!sidebarOwnsHeader && 'laptop:pt-16',
showSidebar &&
(isV2 ? 'tablet:pl-16 laptop:pl-16' : 'tablet:pl-16 laptop:pl-11'),
(isV2 ? v2CollapsedPadding : 'tablet:pl-16 laptop:pl-11'),
className,
isAuthReady &&
showSidebar &&
(sidebarExpanded || forceSidebarExpanded) &&
(isV2
? 'laptop:!pl-[19rem]'
: !isScreenCentered && 'laptop:!pl-60'),
(isV2 ? v2ExpandedPadding : !isScreenCentered && 'laptop:!pl-60'),
isBannerAvailable && !sidebarOwnsHeader && 'laptop:pt-24',
)}
>
Expand All @@ -341,7 +360,9 @@ function MainLayoutComponent({
// card without establishing a scroll container, so descendant
// `position: sticky` elements (e.g. the post action bar) stick
// to the viewport instead of being inert.
'laptop:overflow-clip laptop:rounded-24 laptop:border laptop:border-border-subtlest-quaternary laptop:bg-background-default laptop:p-0.5 laptop:shadow-2',
// No drop shadow — the subtle border defines the floating card
// in both themes; shadow-2 cast a heavy bottom shadow.
'laptop:overflow-clip laptop:rounded-24 laptop:border laptop:border-border-subtlest-quaternary laptop:bg-background-default laptop:p-0.5',
!hasTopBanners &&
!topBanner &&
'laptop:min-h-[calc(100vh-1.5rem)]',
Expand Down
16 changes: 14 additions & 2 deletions packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,23 @@ import { IconSize } from '../Icon';
type Props = WithClassNameProps & {
shouldOpenProfile?: boolean;
profileImageSize?: ProfileImageSize;
// v2 sidebar dropdown tightens the name/handle gap; defaults to the v1 value.
compact?: boolean;
};

export const ProfileMenuHeader = ({
className,
shouldOpenProfile = false,
profileImageSize = ProfileImageSize.Large,
}: Props): ReactElement => {
compact = false,
}: Props): ReactElement | null => {
const { user } = useAuthContext();
const { isPlus } = usePlusSubscription();

if (!user) {
return null;
}

return (
<ConditionalWrapper
condition={shouldOpenProfile}
Expand All @@ -51,7 +58,12 @@ export const ProfileMenuHeader = ({
className="!rounded-10 border-background-default"
/>

<div className="flex min-w-0 flex-1 flex-col gap-1">
<div
className={classNames(
'flex min-w-0 flex-1 flex-col',
compact ? 'gap-0.5' : 'gap-1',
)}
>
<div className="flex items-center gap-1">
<Typography
type={TypographyType.Subhead}
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/components/ProfileMenu/ProfileSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ export type ProfileSectionProps = {
items: Array<ProfileSectionItemProps>;
title?: string;
withSeparator?: boolean;
// Forwarded to every row; v2 reveals external-link icons on hover only.
linkIconHoverOnly?: boolean;
} & WithClassNameProps;

export const ProfileSection = ({
items,
className,
title,
withSeparator,
linkIconHoverOnly,
}: ProfileSectionProps): ReactElement => {
return (
<>
Expand All @@ -39,6 +42,7 @@ export const ProfileSection = ({
{items.map((item) => (
<ProfileSectionItem
{...item}
linkIconHoverOnly={item.linkIconHoverOnly ?? linkIconHoverOnly}
key={`${item.title.trim().toLowerCase().replace(' ', '-')}`}
/>
))}
Expand Down
25 changes: 23 additions & 2 deletions packages/shared/src/components/ProfileMenu/ProfileSectionItem.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import type { ReactElement } from 'react';
import classNames from 'classnames';
import { useRouter } from 'next/router';
import type { WithClassNameProps } from '../utilities';
import Link from '../utilities/Link';
import {
Expand All @@ -22,6 +23,9 @@ type ProfileSectionItemPropsCommon = WithClassNameProps & {
icon?: (props: IconProps) => ReactElement;
onClick?: () => void;
isActive?: boolean;
// v2 reveals the external-link icon only on hover/focus for a cleaner column.
// Defaults to the v1 always-visible icon so the production menu is unchanged.
linkIconHoverOnly?: boolean;
typography?: Partial<{
type: TypographyType;
color: TypographyColor;
Expand Down Expand Up @@ -51,25 +55,38 @@ export const ProfileSectionItem = ({
onClick,
external,
isActive,
linkIconHoverOnly,
typography,
}: ProfileSectionItemProps): ReactElement => {
const router = useRouter();
const isMobile = useViewSize(ViewSize.MobileL);
const tag = href ? TypographyTag.Link : TypographyTag.Button;
const showLinkIcon = href && external;
const openNewTab = showLinkIcon && !href.startsWith(webappUrl);
// Warm the destination chunk while the cursor is on the row so the click
// resolves fast. The dropdown is conditionally mounted, so Next's default
// viewport prefetch never gets a chance before the click otherwise.
const isInternal = !!href && !external && href.startsWith(webappUrl);
const prefetch = () => {
if (isInternal) {
router.prefetch(href).catch(() => undefined);
}
};
const content = (
<Typography<typeof tag>
tag={tag}
color={typography?.color ?? TypographyColor.Tertiary}
type={typography?.type ?? TypographyType.Subhead}
className={classNames(
'flex h-10 cursor-pointer items-center gap-2 rounded-10 px-1 tablet:h-8',
'group flex h-10 cursor-pointer items-center gap-2 rounded-10 px-1 tablet:h-8',
(href || onClick) && 'hover:bg-surface-float',
isActive ? 'bg-surface-active' : undefined,
className,
)}
{...combinedClicks(() => onClick?.())}
{...(openNewTab && { target: '_blank', rel: anchorDefaultRel })}
onMouseEnter={prefetch}
onFocus={prefetch}
>
{Icon && (
<Icon
Expand All @@ -81,7 +98,11 @@ export const ProfileSectionItem = ({

{!isMobile && showLinkIcon && (
<OpenLinkIcon
className="ml-auto text-text-quaternary"
className={classNames(
'ml-auto text-text-quaternary',
linkIconHoverOnly &&
'opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100',
)}
size={IconSize.Size16}
/>
)}
Expand Down
25 changes: 18 additions & 7 deletions packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactElement, ReactNode } from 'react';
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { useInView } from 'react-intersection-observer';
import type { Squad } from '../../../graphql/sources';
import type { SourcesQueryProps } from '../../../hooks/source/useSources';
Expand All @@ -26,6 +27,7 @@ import type { Ad } from '../../../graphql/posts';
import { AdPixel } from '../ad/common/AdPixel';
import { TargetType } from '../../../lib/log';
import { useAdQuery } from '../../../features/monetization/useAdQuery';
import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant';

interface SquadHorizontalListProps {
title: HorizontalScrollTitleProps;
Expand Down Expand Up @@ -88,11 +90,12 @@ export function SquadsDirectoryFeed({
className,
children,
firstItemShouldBeAd = false,
}: SquadHorizontalListProps): ReactElement {
}: SquadHorizontalListProps): ReactElement | null {
const { ref, inView } = useInView({
triggerOnce: true,
});
const { user, isAuthReady } = useAuthContext();
const { isV2 } = useLayoutVariant();
const { result } = useSources<Squad>({ query, isEnabled: inView });
const { isFetched } = result;
const isMobile = useViewSize(ViewSize.MobileL);
Expand All @@ -107,7 +110,9 @@ export function SquadsDirectoryFeed({
),
enabled: firstItemShouldBeAd && isAuthReady && !user?.isPlus,
});
const { squad: squadAd } = useSquad({ handle: ad?.data?.source?.handle });
const { squad: squadAd } = useSquad({
handle: ad?.data?.source?.handle ?? '',
});
const flatSources = useMemo(() => {
const map = getFlatteredNodes(result);

Expand Down Expand Up @@ -148,8 +153,8 @@ export function SquadsDirectoryFeed({
const isAd = ad && index === 0;

return (
<SquadItemLogExtraContext key={node.id} ad={ad}>
<SquadList squad={node} ad={isAd ? ad : undefined}>
<SquadItemLogExtraContext key={node.id} ad={ad ?? undefined}>
<SquadList squad={node} ad={isAd ? ad ?? undefined : undefined}>
{!!ad?.pixel && <AdPixel pixel={ad.pixel} />}
</SquadList>
</SquadItemLogExtraContext>
Expand All @@ -163,7 +168,13 @@ export function SquadsDirectoryFeed({
return (
<HorizontalScroll
ref={ref}
className={{ container: className, scroll: 'gap-6' }}
className={{
container: className,
// v2: bleed the scroll row past the page gutters (laptop:px-6) so the
// cards run continuously to the edge of the feed area instead of being
// clipped inside the padding. Re-pad so the first card stays aligned.
scroll: classNames('gap-6', isV2 && 'laptop:-mx-6 laptop:px-6'),
}}
scrollProps={{ title, linkToSeeAll }}
>
{children}
Expand All @@ -175,12 +186,12 @@ export function SquadsDirectoryFeed({
(node.flags?.featured && linkToSeeAll.includes('featured'));

return showFeaturedCard ? (
<SquadItemLogExtraContext key={node.id} ad={ad}>
<SquadItemLogExtraContext key={node.id} ad={ad ?? undefined}>
<SquadGrid
source={node}
className="w-80"
border={shouldShowAd ? SourceCardBorderColor.Pepper : undefined}
ad={shouldShowAd ? ad : undefined}
ad={shouldShowAd ? ad ?? undefined : undefined}
>
{!!ad?.pixel && <AdPixel pixel={ad.pixel} />}
</SquadGrid>
Expand Down
20 changes: 20 additions & 0 deletions packages/shared/src/components/header/ExploreHubHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import { PageHeader } from '../layout/PageHeader';
import { ExploreSectionTabs } from './ExploreSectionTabs';

// Shared v2 header for the Explore hub's directory pages (Tags, Sources,
// Leaderboard, Best of). Keeps the section-tab strip and its height
// (`!py-0`) consistent in one place. Optional children render as header
// actions (e.g. the "Suggest source" button).
export function ExploreHubHeader({
children,
}: {
children?: ReactNode;
}): ReactElement {
return (
<PageHeader title={<ExploreSectionTabs />} className="!py-0">
{children}
</PageHeader>
);
}
Loading
Loading