diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index feeafd84127..a6d014434b1 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -88,6 +88,8 @@ import { useReadingReminderHero } from '../hooks/notifications/useReadingReminde import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { useReadingReminderVariation } from '../hooks/notifications/useReadingReminderVariation'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; +import { ExploreSectionTabs } from './header/ExploreSectionTabs'; +import { ExploreSortDropdown } from './header/ExploreSortDropdown'; const FeedExploreHeader = dynamic( () => @@ -263,6 +265,7 @@ export default function MainFeedLayout({ isPopular, isAnyExplore, isExploreLatest, + isDiscussed, isSortableFeed, isCustomFeed, isSearch: isSearchPage, @@ -726,7 +729,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 @@ -765,13 +771,8 @@ export default function MainFeedLayout({ <> {showExploreV2PageHeader && (
- + + {isAnyExplore && }
)} {showFeedV2PageHeader && ( diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 81a7dc9b0ae..e3ab6d50043 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -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, @@ -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; @@ -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, @@ -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. @@ -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', )} > @@ -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)]', diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx index dc615137a9c..5440fb87eb5 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx @@ -27,10 +27,14 @@ export const ProfileMenuHeader = ({ className, shouldOpenProfile = false, profileImageSize = ProfileImageSize.Large, -}: Props): ReactElement => { +}: Props): ReactElement | null => { const { user } = useAuthContext(); const { isPlus } = usePlusSubscription(); + if (!user) { + return null; + } + return ( -
+
{ + 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 = ( 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 && ( )} diff --git a/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx b/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx index 12793f169db..c7f597b4cbe 100644 --- a/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx +++ b/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx @@ -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'; @@ -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; @@ -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({ query, isEnabled: inView }); const { isFetched } = result; const isMobile = useViewSize(ViewSize.MobileL); @@ -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); @@ -148,8 +153,8 @@ export function SquadsDirectoryFeed({ const isAd = ad && index === 0; return ( - - + + {!!ad?.pixel && } @@ -163,7 +168,13 @@ export function SquadsDirectoryFeed({ return ( {children} @@ -175,12 +186,12 @@ export function SquadsDirectoryFeed({ (node.flags?.featured && linkToSeeAll.includes('featured')); return showFeaturedCard ? ( - + {!!ad?.pixel && } diff --git a/packages/shared/src/components/fields/Dropdown.tsx b/packages/shared/src/components/fields/Dropdown.tsx index 750ec33ceca..4a5458285e2 100644 --- a/packages/shared/src/components/fields/Dropdown.tsx +++ b/packages/shared/src/components/fields/Dropdown.tsx @@ -143,12 +143,14 @@ export function Dropdown({ size={buttonSize} disabled={disabled} className={classNames( - // `!pl-4 !pr-2.5` overrides the Button's built-in Large padding (px-6) - // so the value lines up with the other fields' 16px text inset and the - // chevron sits tight to the right edge instead of floating 24px in. - 'group flex w-full items-center !pl-4 !pr-2.5 font-normal text-text-secondary typo-body hover:bg-surface-hover hover:text-text-primary', + 'group flex items-center font-normal text-text-secondary typo-body hover:bg-surface-hover hover:text-text-primary', + // Value dropdowns get `!pl-4 !pr-2.5` so the value lines up with other + // fields' 16px text inset and the chevron sits tight to the right edge. + // Icon-only dropdowns drop that and render as a square instead. + iconOnly + ? 'aspect-square justify-center !px-0' + : 'w-full !pl-4 !pr-2.5', className?.button, - iconOnly && 'items-center justify-center', )} onClick={fullScreen ? handleMenuTrigger : undefined} onKeyDown={handleKeyboard} diff --git a/packages/shared/src/components/header/ExploreHubHeader.tsx b/packages/shared/src/components/header/ExploreHubHeader.tsx new file mode 100644 index 00000000000..d8c81cfbd12 --- /dev/null +++ b/packages/shared/src/components/header/ExploreHubHeader.tsx @@ -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 ( + } className="!py-0"> + {children} + + ); +} diff --git a/packages/shared/src/components/header/ExploreSectionTabs.tsx b/packages/shared/src/components/header/ExploreSectionTabs.tsx new file mode 100644 index 00000000000..c17a808461c --- /dev/null +++ b/packages/shared/src/components/header/ExploreSectionTabs.tsx @@ -0,0 +1,53 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useRouter } from 'next/router'; +import { + SquadDirectoryNavbar, + SquadDirectoryNavbarItem, +} from '../squads/layout/SquadDirectoryNavbar'; +import { ButtonSize } from '../buttons/Button'; + +type ExploreSection = { + label: string; + path: string; + // The tab is active when the current path equals `match` or sits under it + // (e.g. /tags/react keeps the Tags tab active). + match: string; +}; + +const sections: ExploreSection[] = [ + { label: 'Explore', path: '/posts', match: '/posts' }, + { label: 'Tags', path: '/tags', match: '/tags' }, + { label: 'Sources', path: '/sources', match: '/sources' }, + { label: 'Leaderboard', path: '/users', match: '/users' }, + { label: 'Discussions', path: '/discussed', match: '/discussed' }, +]; + +// Primary navbar for the unified Explore hub (v2). Sits above the Explore +// feed's sort tabs and on the Tags/Sources/Leaderboard/Discussions pages so +// the sections stay one click apart after Discover was folded into Home. +export function ExploreSectionTabs(): ReactElement { + const router = useRouter(); + const currentPath = (router.asPath || router.pathname).split('?')[0]; + + return ( + + {sections.map((section) => ( + + ))} + + ); +} diff --git a/packages/shared/src/components/header/ExploreSortDropdown.tsx b/packages/shared/src/components/header/ExploreSortDropdown.tsx new file mode 100644 index 00000000000..f58d5fb0c70 --- /dev/null +++ b/packages/shared/src/components/header/ExploreSortDropdown.tsx @@ -0,0 +1,62 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useRouter } from 'next/router'; +import { Dropdown } from '../fields/Dropdown'; +import { CalendarIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ExploreTabs, tabToUrl, urlToTab } from './FeedExploreHeader'; +import { QueryStateKeys, useQueryState } from '../../hooks/utils/useQueryState'; +import { periodTexts } from '../layout/common'; + +const sortLabels = Object.values(ExploreTabs); +const sortsWithPeriod: ExploreTabs[] = [ + ExploreTabs.MostUpvoted, + ExploreTabs.BestDiscussions, +]; + +// v2 Explore: switch the feed's ranking via a "Sort" dropdown rather than a +// second row of tabs — sorting a feed isn't navigating to a sibling page, so +// a dropdown reads cleaner (Reddit/GitHub pattern). Each sort is still its +// own route, so selecting one navigates. +export function ExploreSortDropdown(): ReactElement { + const router = useRouter(); + const currentPath = (router.asPath || router.pathname).split('?')[0]; + const activeTab = urlToTab[currentPath] ?? ExploreTabs.Popular; + const selectedIndex = Math.max(0, sortLabels.indexOf(activeTab)); + const [period, setPeriod] = useQueryState({ + key: [QueryStateKeys.FeedPeriod], + defaultValue: 0, + }); + + return ( + + {sortsWithPeriod.includes(activeTab) && ( + } + buttonSize={ButtonSize.Small} + buttonVariant={ButtonVariant.Float} + selectedIndex={period} + options={periodTexts} + onChange={(_, index) => setPeriod(index)} + buttonAriaLabel="Filter by date range" + /> + )} + { + const url = tabToUrl[value as ExploreTabs]; + if (url) { + router.push(url).catch(() => undefined); + } + }} + /> + + ); +} diff --git a/packages/shared/src/components/icons/Calendar/filled.svg b/packages/shared/src/components/icons/Calendar/filled.svg index d16a2ad4697..32501a76984 100644 --- a/packages/shared/src/components/icons/Calendar/filled.svg +++ b/packages/shared/src/components/icons/Calendar/filled.svg @@ -1,7 +1,4 @@ - - - Icon/Calendar/Filled - - - - \ No newline at end of file + +Icon/Calendar/Filled + + diff --git a/packages/shared/src/components/icons/Calendar/outlined.svg b/packages/shared/src/components/icons/Calendar/outlined.svg index b95637af6d0..62c1598f655 100644 --- a/packages/shared/src/components/icons/Calendar/outlined.svg +++ b/packages/shared/src/components/icons/Calendar/outlined.svg @@ -1,7 +1,10 @@ - - - Icon/Calendar/Outline - - - - \ No newline at end of file + +Icon/Calendar/Outline + + + + + + + + diff --git a/packages/shared/src/components/icons/Help/filled.svg b/packages/shared/src/components/icons/Help/filled.svg new file mode 100644 index 00000000000..76a9ae1aae6 --- /dev/null +++ b/packages/shared/src/components/icons/Help/filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/shared/src/components/icons/Help/index.tsx b/packages/shared/src/components/icons/Help/index.tsx new file mode 100644 index 00000000000..9b5f279992a --- /dev/null +++ b/packages/shared/src/components/icons/Help/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const HelpIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Help/outlined.svg b/packages/shared/src/components/icons/Help/outlined.svg new file mode 100644 index 00000000000..9655c5d3283 --- /dev/null +++ b/packages/shared/src/components/icons/Help/outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/shared/src/components/icons/NewPost/filled.svg b/packages/shared/src/components/icons/NewPost/filled.svg new file mode 100644 index 00000000000..a959653f4b7 --- /dev/null +++ b/packages/shared/src/components/icons/NewPost/filled.svg @@ -0,0 +1,4 @@ + +Icon/NewPost/Filled + + diff --git a/packages/shared/src/components/icons/NewPost/index.tsx b/packages/shared/src/components/icons/NewPost/index.tsx new file mode 100644 index 00000000000..119c6b9ce18 --- /dev/null +++ b/packages/shared/src/components/icons/NewPost/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const NewPostIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/NewPost/outlined.svg b/packages/shared/src/components/icons/NewPost/outlined.svg new file mode 100644 index 00000000000..88448019d43 --- /dev/null +++ b/packages/shared/src/components/icons/NewPost/outlined.svg @@ -0,0 +1,4 @@ + +Icon/NewPost/Outline + + diff --git a/packages/shared/src/components/icons/gift/filled.svg b/packages/shared/src/components/icons/gift/filled.svg index 2ff3f78043a..7a1b6529625 100644 --- a/packages/shared/src/components/icons/gift/filled.svg +++ b/packages/shared/src/components/icons/gift/filled.svg @@ -1,3 +1,8 @@ - + + + + + + diff --git a/packages/shared/src/components/icons/gift/outlined.svg b/packages/shared/src/components/icons/gift/outlined.svg index d6ae51251a6..923576a8755 100644 --- a/packages/shared/src/components/icons/gift/outlined.svg +++ b/packages/shared/src/components/icons/gift/outlined.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 37ba3f92871..a8506ac6745 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -72,6 +72,7 @@ export * from './Hamburger'; export * from './Hammer'; export * from './Hashnode'; export * from './Hashtag'; +export * from './Help'; export * from './Home'; export * from './Hot'; export * from './Image'; @@ -108,6 +109,7 @@ export * from './Minimize'; export * from './Minus'; export * from './Moon'; export * from './MoveTo'; +export * from './NewPost'; export * from './NewSquad'; export * from './NewTab'; export * from './NumberedList'; diff --git a/packages/shared/src/components/modals/post/SmartComposerModal.tsx b/packages/shared/src/components/modals/post/SmartComposerModal.tsx index 22037f4029f..47e9888c620 100644 --- a/packages/shared/src/components/modals/post/SmartComposerModal.tsx +++ b/packages/shared/src/components/modals/post/SmartComposerModal.tsx @@ -89,6 +89,7 @@ const resolveDefaultKind = ( export interface SmartComposerModalProps extends LazyModalCommonProps { initialUrl?: string; initialSquadHandle?: string; + initialKind?: ComposerKind; preview?: ExternalLinkPreview; editPost?: Post; } @@ -97,6 +98,7 @@ export function SmartComposerModal({ onRequestClose, initialUrl, initialSquadHandle, + initialKind, preview: initialPreview, editPost, ...props @@ -118,6 +120,9 @@ export function SmartComposerModal({ if (initialUrl) { return 'link'; } + if (initialKind) { + return initialKind; + } return resolveDefaultKind(flags?.defaultWriteTab, isStandupEnabled); }); // Settings load async; if the modal opens before they're ready, apply the @@ -129,7 +134,7 @@ export function SmartComposerModal({ return; } hasAppliedDefaultKind.current = true; - if (isEditing || initialUrl || hasUserChangedKind.current) { + if (isEditing || initialUrl || initialKind || hasUserChangedKind.current) { return; } setKind(resolveDefaultKind(flags?.defaultWriteTab, isStandupEnabled)); @@ -137,6 +142,7 @@ export function SmartComposerModal({ loadedSettings, isEditing, initialUrl, + initialKind, flags?.defaultWriteTab, isStandupEnabled, ]); diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 263d57185bf..0b17ebff20a 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -14,16 +14,20 @@ import { useViewSize, ViewSize } from '../../hooks'; import { Tooltip } from '../tooltip/Tooltip'; import Link from '../utilities/Link'; import { IconSize } from '../Icon'; +import { railTabClass, railTabLabelClass } from '../sidebar/common'; function NotificationsBell({ compact, rail, noTooltip, + railHideLabel, active, }: { compact?: boolean; rail?: boolean; noTooltip?: boolean; + // v2 rail compact mode: hide the "Alerts" label under the bell. + railHideLabel?: boolean; // Optional override — the v2 sidebar wants the bell highlighted on // any page that owns the Notifications category (incl. its settings // sub-page), which extends past the bell's own internal check. @@ -61,23 +65,26 @@ function NotificationsBell({ href={`${webappUrl}notifications`} aria-label="Notifications" className={classNames( - 'focus-outline relative flex h-10 w-10 items-center justify-center rounded-12 transition-colors hover:bg-surface-hover hover:text-text-primary', - atNotificationsPage - ? 'bg-background-default text-text-primary' - : 'text-text-tertiary', + railTabClass, + atNotificationsPage && 'bg-background-default !text-text-primary', )} onClick={onNavigateNotifications} > - - {hasNotification && ( - - {getUnreadText(unreadCount)} - + + + {hasNotification && ( + + {getUnreadText(unreadCount)} + + )} + + {!railHideLabel && ( + Alerts )} diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx index 0410d53f747..939dfc7d524 100644 --- a/packages/shared/src/components/sidebar/Section.tsx +++ b/packages/shared/src/components/sidebar/Section.tsx @@ -5,6 +5,7 @@ import type { ItemInnerProps, SidebarMenuItem } from './common'; import { NavHeader, NavSection } from './common'; import { SidebarItem } from './SidebarItem'; import { ArrowIcon, PlusIcon } from '../icons'; +import { IconSize } from '../Icon'; import type { SettingsFlags } from '../../graphql/settings'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { isNullOrUndefined } from '../../lib/func'; @@ -65,10 +66,10 @@ export function Section({ }; return ( - + {title && ( - {/* Divider shown when sidebar is collapsed */} + {/* Divider shown when a collapsible (titled) section is collapsed */}
@@ -105,8 +106,14 @@ export function Section({ {title} @@ -138,12 +145,16 @@ export function Section({ id={flag ? `section-${flag}` : undefined} className={classNames( 'grid transition-[grid-template-rows,opacity] duration-300', - isVisible.current || shouldAlwaysBeVisible + // Collapsing only applies when there's a title (the header is the + // only toggle). A flagged-but-title-less section — e.g. the Squads + // and Saved panels — would otherwise get stuck hidden when its flag + // is false, with no arrow to re-expand it. + !title || isVisible.current || shouldAlwaysBeVisible ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', )} > -
+
{items.map((item) => ( ), }, - { - id: SidebarCategory.Discover, - label: 'Discover', - // Discover's first sub-page is the Explore feed (`/posts`); clicking - // the rail icon should land you on that page, matching the submenu. - defaultPath: `${webappUrl}posts`, - icon: (active) => ( - - ), - }, - { - id: SidebarCategory.Saved, - label: 'Saved', - defaultPath: `${webappUrl}bookmarks`, - icon: (active) => ( - - ), - }, { id: SidebarCategory.GameCenter, - label: 'Game Center', + label: 'Quests', // First sub-page in the Game Center category is the Daily quests // page (the panel that used to live in the sidebar). Clicking the // rail icon lands you there; Game Center proper is one click away @@ -123,28 +155,36 @@ const sidebarCategories: SidebarCategoryConfig[] = [ icon: (active) => , }, { - id: SidebarCategory.Profile, - label: 'Profile', + id: SidebarCategory.Saved, + label: 'Saved', + defaultPath: `${webappUrl}bookmarks`, icon: (active) => ( - + ), }, ]; const railButtonClass = - 'flex h-10 w-10 items-center justify-center rounded-12 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus-outline'; + 'flex size-10 items-center justify-center rounded-12 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus-outline'; +// Shared group so the rail's click popups (support, profile menu, streak) are +// mutually exclusive — opening one closes the others. +const RAIL_POPUP_GROUP = 'sidebar-rail'; +// How long the urgency tooltip auto-surfaces when the streak turns critical. +const STREAK_CRITICAL_TOOLTIP_MS = 5000; const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K']; const settingsDefaultPath = `${settingsUrl}/profile`; const RAIL_HOVER_OPEN_DELAY = 300; const RAIL_HOVER_CLOSE_DELAY = 120; const RAIL_HOVER_SIDE_OFFSET = 12; -const RAIL_HOVER_PROFILE_ALIGN_OFFSET = -304; // The shared Tooltip primitive bakes in `collisionPadding={{ top: 75 }}` — // a leftover from the global-header layout. With the dual-sidebar there's // no top chrome to clip against, so a snug override re-centers tooltips // with their triggers. const RAIL_TOOLTIP_COLLISION_PADDING = 4; +// Vertical slack (px) added to the safe-zone triangle so the pointer can dip +// slightly past the panel's top/bottom edge while arcing in without losing it. +const SAFE_ZONE_BUFFER = 26; interface RailHoverCardProps { label: string; @@ -218,54 +258,58 @@ const RailHoverCard = ({ ); }; -const themeIconMap: Record< - ThemeMode, - React.ComponentType<{ - secondary?: boolean; - size?: IconSize; - 'aria-hidden'?: boolean; - }> -> = { - [ThemeMode.Dark]: MoonIcon, - [ThemeMode.Light]: SunIcon, - [ThemeMode.Auto]: ThemeAutoIcon, -}; +// Theme toggling now lives in the profile dropdown (ThemeSection, matching +// production). The rail slot is reused for a quick "Invite friends" shortcut. +const SidebarInviteButton = (): ReactElement => ( + + + + + + + +); -const SidebarThemeButton = (): ReactElement => { - const { setTheme, themeMode } = useSettingsContext(); - const { logEvent } = useLogContext(); - const isDark = themeMode === ThemeMode.Dark; - const nextMode = isDark ? ThemeMode.Light : ThemeMode.Dark; - const ActiveIcon = themeIconMap[themeMode]; - - const onToggleTheme = useCallback(() => { - logEvent({ - event_name: LogEvent.ChangeSettings, - target_type: TargetType.Theme, - target_id: nextMode, - }); - setTheme(nextMode); - }, [logEvent, nextMode, setTheme]); +const supportItems: ProfileSectionItemProps[] = [ + { + title: 'Get the mobile app', + href: appsUrl, + icon: PhoneIcon, + external: true, + }, + { + title: 'Get the browser extension', + href: downloadBrowserExtension, + icon: BrowserGroupIcon, + external: true, + }, + { + title: 'Changelog', + href: `${webappUrl}sources/daily_updates`, + icon: TerminalIcon, + }, + { title: 'Docs', href: docs, icon: DocsIcon, external: true }, + { title: 'Report a bug', href: feedback, icon: FlagIcon, external: true }, +]; - return ( - - - - ); -}; +const legalItems: ProfileSectionItemProps[] = [ + { + title: 'Privacy policy', + href: privacyPolicy, + icon: PrivacyIcon, + external: true, + }, + { + title: 'Terms of service', + href: termsOfService, + icon: DocsIcon, + external: true, + }, +]; const SidebarSupportButton = (): ReactElement => { - const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); + const { isOpen, onUpdate, wrapHandler } = + useInteractivePopup(RAIL_POPUP_GROUP); return ( <> @@ -280,7 +324,7 @@ const SidebarSupportButton = (): ReactElement => { isOpen && 'bg-background-default !text-text-primary', )} > - + {isOpen && ( @@ -291,9 +335,291 @@ const SidebarSupportButton = (): ReactElement => { className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" > - + - + + + )} + + ); +}; + +// Options for the rail "+" hover panel. Each opens the composer modal with the +// matching kind preselected (not a dedicated page). Built as SidebarMenuItem +// rows so the list matches the other category panels. +const createMenuOptions: { + title: string; + kind: ComposerKind; + icon: (active: boolean) => ReactElement; +}[] = [ + { + title: 'Free form', + kind: 'text', + icon: (active) => } />, + }, + { + title: 'Share a link', + kind: 'link', + icon: (active) => } />, + }, + { + title: 'Poll', + kind: 'poll', + icon: (active) => } />, + }, + { + title: 'Live', + kind: 'standup', + icon: (active) => ( + } /> + ), + }, +]; + +// Profile menu anchored to the bottom rail avatar. A curated, lean subset of +// the production ProfileMenu (built from the shared ProfileSection item rows) +// plus the rail-specific reputation/cores stats card. +const SidebarProfileButton = ({ + onPreviewHref, +}: { + onPreviewHref: (href: string) => void; +}): ReactElement | null => { + const { user, logout } = useAuthContext(); + const { isOpen, onUpdate, wrapHandler } = + useInteractivePopup(RAIL_POPUP_GROUP); + const { openModal } = useLazyModal(); + const canPurchaseCores = useCanPurchaseCores(); + // The reading streak is one connected colored shape behind the avatar: the + // border around the avatar + a peeking tab share the fill (state colour). The + // avatar opens the profile menu; the tab opens the streak calendar. + const { + isEnabled: isStreakEnabled, + isLoading: isStreakLoading, + streak, + state: streakState, + count: streakCount, + hasReadToday, + copy: streakCopy, + } = useStreakRingState(); + const { isOpen: isStreakOpen, onUpdate: setStreakOpen } = + useInteractivePopup(RAIL_POPUP_GROUP); + const streakChipRef = useRef(null); + // Only on critical: auto-open the streak tooltip to nudge the user for ~5s, + // then hide it (or sooner, the moment they hover the streak). Re-arms each + // time the streak re-enters the critical state. + const [autoOpenStreakTooltip, setAutoOpenStreakTooltip] = useState(false); + const prevStreakCriticalRef = useRef(false); + useEffect(() => { + const isCritical = streakState === 'critical'; + const wasCritical = prevStreakCriticalRef.current; + prevStreakCriticalRef.current = isCritical; + if (isCritical && !wasCritical) { + setAutoOpenStreakTooltip(true); + const timeout = setTimeout( + () => setAutoOpenStreakTooltip(false), + STREAK_CRITICAL_TOOLTIP_MS, + ); + return () => clearTimeout(timeout); + } + if (!isCritical) { + setAutoOpenStreakTooltip(false); + } + return undefined; + }, [streakState]); + + if (!user) { + return null; + } + + // Optimistically switch the context panel to the link's category on click — + // same instant feedback as a rail-tab click — so the panel doesn't visibly + // lag a slow route transition (especially the heavy Settings pages). + const withPreview = ( + items: ProfileSectionItemProps[], + ): ProfileSectionItemProps[] => + items.map((item) => { + if (!item.href || item.external) { + return item; + } + const { href, onClick } = item; + return { + ...item, + onClick: () => { + onPreviewHref(href); + onClick?.(); + }, + }; + }); + + const mainItems: ProfileSectionItemProps[] = [ + { title: 'Analytics', href: `${webappUrl}analytics`, icon: AnalyticsIcon }, + { title: 'Jobs', href: `${webappUrl}jobs`, icon: JobIcon }, + { + title: 'DevCard', + href: `${settingsUrl}/customization/devcard`, + icon: DevCardIcon, + }, + { + title: 'Invite friends', + href: `${settingsUrl}/invite`, + icon: InviteIcon, + }, + ]; + + const billingItems: ProfileSectionItemProps[] = [ + { + title: 'Subscriptions', + href: `${settingsUrl}/subscription`, + icon: CreditCardIcon, + }, + ...(canPurchaseCores + ? [ + { + title: 'Ads dashboard', + icon: TrendingIcon, + onClick: () => openModal({ type: LazyModal.AdsDashboard }), + } satisfies ProfileSectionItemProps, + ] + : []), + { + title: 'Advertise', + href: businessWebsiteUrl, + icon: MegaphoneIcon, + external: true, + }, + ]; + + const settingsItems: ProfileSectionItemProps[] = [ + { title: 'Settings', href: settingsDefaultPath, icon: SettingsIcon }, + { title: 'Appearance', href: `${settingsUrl}/appearance`, icon: EyeIcon }, + { + title: 'Feed settings', + href: `${settingsUrl}/feed/general`, + icon: AppIcon, + }, + ]; + + const logoutItems: ProfileSectionItemProps[] = [ + { + title: 'Log out', + icon: ExitIcon, + onClick: () => logout(LogoutReason.ManualLogout), + }, + ]; + + return ( + <> +
+ {isStreakEnabled ? ( + // Shared StreakRing renders the "border legend" visual (avatar in a + // bordered box; flame + count break through the bottom border). The + // avatar (profile menu) and the chip (streak popover) are two distinct + // buttons — we pass the avatar button + the chip's interactivity here; + // all state visuals live in StreakRing / useStreakRingState. + { + event.stopPropagation(); + setStreakOpen(!isStreakOpen); + }} + chipTooltip={streakCopy} + chipTooltipOpen={autoOpenStreakTooltip} + onMouseEnter={() => setAutoOpenStreakTooltip(false)} + avatar={ + + + + } + /> + ) : ( + + )} +
+ {isStreakOpen && streak && ( + setStreakOpen(false)} + placement="bottom" + /> + )} + {isOpen && ( + onUpdate(false)} + position={InteractivePopupPosition.SidebarProfileMenu} + className="flex max-h-[calc(100dvh-4rem)] w-72 flex-col gap-3 overflow-y-auto !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" + > + + + + + + + + + + )} @@ -332,30 +658,103 @@ export const SidebarDesktopV2 = ({ const { logEvent } = useLogContext(); const { isAvailable: isBannerAvailable } = useBanner(); const { open: openSpotlight } = useSpotlight(); + const { openModal } = useLazyModal(); + const { isLoggedIn } = useAuthContext(); const { openNewSquad } = useSquadNavigation(); - const { isLoggedIn, user } = useAuthContext(); + const addBookmarkFolder = useAddBookmarkFolder(); + const { value: isCompact } = useSettingsBooleanFlag('sidebarCompact'); + // Compact mode reverts to the original icon-only widths (pre-label rail). + // Both width sets are known-good; MainLayout mirrors the collapsed/expanded + // padding so the content never overlaps the rail. + const railCollapsedWidth = isCompact ? 'laptop:w-16' : 'laptop:w-20'; + const railExpandedWidth = isCompact ? 'laptop:w-[19rem]' : 'laptop:w-[20rem]'; + const railNavWidth = isCompact ? 'w-16' : 'w-20'; + const railSeparatorLeft = isCompact ? 'left-16' : 'left-20'; + const railToggleClosedLeft = isCompact ? 'left-[3.5rem]' : 'left-[4.5rem]'; + const railToggleOpenLeft = isCompact ? 'left-[16.5rem]' : 'left-[17.5rem]'; const claimableQuestCount = useClaimableQuestCount(); const showQuestBadge = !optOutQuestSystem && claimableQuestCount > 0; + + // --- Vertical "More" overflow ------------------------------------------- + // When the rail is too short to fit every nav item, the lowest-priority + // items fold into the Settings button, which becomes a 3-dots "More" + // dropdown. Fold order: Saved, then Quests, then Alerts. Home, Squads and + // New post always stay. Measured against the nav list's height so it tracks + // the viewport like Slack's sidebar. + const navListRef = useRef(null); + const [maxNavSlots, setMaxNavSlots] = useState(Number.POSITIVE_INFINITY); + useEffect(() => { + const list = navListRef.current; + if (!list || typeof ResizeObserver === 'undefined') { + return undefined; + } + const GAP = 4; + const itemHeight = isCompact ? 44 : 56; + const measure = () => { + // Ignore zero-height measurements (e.g. a hidden/not-yet-laid-out mount) + // so we don't briefly fold every item into the "More" menu. + if (list.clientHeight <= 0) { + return; + } + const slots = Math.floor((list.clientHeight + GAP) / (itemHeight + GAP)); + setMaxNavSlots(Math.max(0, slots)); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(list); + return () => observer.disconnect(); + }, [isCompact]); + + const overflowOrder = useMemo( + () => + [ + isLoggedIn ? SidebarCategory.Notifications : null, + SidebarCategory.GameCenter, + SidebarCategory.Saved, + ].filter(Boolean) as SidebarCategoryId[], + [isLoggedIn], + ); + // Home, Squads and (logged-in) New post never fold. The 3-dots "More" + // button only appears when something overflows, so it costs a slot then. + const fixedNavSlots = 2 + (isLoggedIn ? 1 : 0); + const isNavOverflowing = maxNavSlots < fixedNavSlots + overflowOrder.length; + const visibleOverflowCount = isNavOverflowing + ? Math.max( + 0, + Math.min(overflowOrder.length, maxNavSlots - fixedNavSlots - 1), + ) + : overflowOrder.length; + const foldedNavIds = overflowOrder.slice(visibleOverflowCount); const activePage = activePageProp || router.asPath || router.pathname || ''; - const isUserProfileActive = - !!user?.username && activePage.includes(`/${user.username}`); const isFeedPage = activePage.includes('/feeds/'); const resolvedCategory = useMemo((): SidebarCategoryId => { if (isFeedPage) { return SidebarCategory.Main; } - if (isUserProfileActive) { - return SidebarCategory.Profile; - } return getSidebarCategoryForPath(activePage); - }, [activePage, isFeedPage, isUserProfileActive]); + }, [activePage, isFeedPage]); // Optimistic override so a rail click feels instant even when // router.push is async. Cleared once the URL catches up. const [pendingCategory, setPendingCategory] = useState(null); const selectedCategory = pendingCategory ?? resolvedCategory; + // On settings pages the sidebar collapses to a single full-width settings + // panel (no rail), so hover-preview is irrelevant — pin the panel to Settings. + const isSettingsSelected = selectedCategory === SidebarCategory.Settings; + + // Hovering a rail tab previews that category's panel without committing to + // it; the panel falls back to the selected/pinned category. Cleared when the + // cursor leaves the sidebar (see handleRailMouseLeave). + const [hoveredCategory, setHoveredCategory] = + useState(null); + const activeCategory = isSettingsSelected + ? SidebarCategory.Settings + : hoveredCategory ?? selectedCategory; + // Hovering the "+" previews the create-post options panel (takes precedence + // over a hovered category). Clicking "+" opens the composer modal instead. + const [isCreateHovered, setIsCreateHovered] = useState(false); useEffect(() => { if (pendingCategory !== null && pendingCategory === resolvedCategory) { @@ -370,6 +769,19 @@ export const SidebarDesktopV2 = ({ // in. Keep transitions off until settings have loaded so the sidebar // snaps into its final state on first paint and only genuine user // toggles animate afterwards. + const [isRailHovered, setIsRailHovered] = useState(false); + // After a click-to-collapse the cursor is still over the sidebar. Suppress + // the hover-peek until it actually leaves and re-enters, so the first click + // collapses instead of instantly re-expanding under the cursor. + const peekSuppressedRef = useRef(false); + // Prediction-cone "safe zone": while the pointer arcs from the active tab + // into the panel, block the rail tabs' pointer events so clipping a + // neighbouring tab can't switch the preview (menu-aim done with pointer + // blocking rather than fragile slope guesses). + const panelRef = useRef(null); + const safeBlockedRef = useRef(false); + const safePolyRef = useRef | null>(null); + const lastPointerRef = useRef<{ x: number; y: number } | null>(null); const [transitionsEnabled, setTransitionsEnabled] = useState(false); useEffect(() => { if (loadedSettings) { @@ -403,9 +815,6 @@ export const SidebarDesktopV2 = ({ const getCategoryDefaultPath = useCallback( (category: SidebarCategoryId): string | null => { - if (category === SidebarCategory.Profile) { - return user?.username ? `${webappUrl}${user.username}` : null; - } if (category === SidebarCategory.Settings) { return settingsDefaultPath; } @@ -414,7 +823,7 @@ export const SidebarDesktopV2 = ({ null ); }, - [user?.username], + [], ); const onSelectCategory = useCallback( @@ -451,13 +860,198 @@ export const SidebarDesktopV2 = ({ [getCategoryDefaultPath, router], ); + // Profile-dropdown links navigate via `` and bypass `onSelectCategory`, + // so the panel would otherwise wait for the route to resolve before swapping. + // Map the link's path to its category and switch optimistically on click. + const onPreviewHref = useCallback((href: string) => { + const { pathname } = new URL(href, 'http://_'); + setPendingCategory(getSidebarCategoryForPath(pathname)); + }, []); + + // Remember the last non-settings location so "Back to app" returns the user + // where they were rather than always dumping them on the home feed. + const lastAppPathRef = useRef(webappUrl); + useEffect(() => { + if (getSidebarCategoryForPath(activePage) !== SidebarCategory.Settings) { + lastAppPathRef.current = activePage; + } + }, [activePage]); + + const onBackToApp = useCallback(() => { + setPendingCategory(SidebarCategory.Main); + Promise.resolve(router.push(lastAppPathRef.current)).catch(() => undefined); + }, [router]); + + // Entering settings collapses the rail, so any stale hover/create preview + // would otherwise leak into the settings panel — clear it. + useEffect(() => { + if (isSettingsSelected) { + setHoveredCategory(null); + setIsCreateHovered(false); + } + }, [isSettingsSelected]); + const onToggleExpanded = useCallback(() => { logEvent({ event_name: `${sidebarExpanded ? 'open' : 'close'} sidebar`, }); + if (sidebarExpanded) { + peekSuppressedRef.current = true; + setIsRailHovered(false); + } toggleSidebarExpanded(); }, [logEvent, sidebarExpanded, toggleSidebarExpanded]); + // `[` toggles the sidebar open/closed (mirrors the collapse toggle button). + // Skipped while typing into a field so it doesn't hijack the bracket key. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== '[' || event.metaKey || event.ctrlKey || event.altKey) { + return; + } + const target = event.target as HTMLElement | null; + if ( + target?.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '') + ) { + return; + } + onToggleExpanded(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onToggleExpanded]); + + const handleRailMouseEnter = useCallback(() => { + if (peekSuppressedRef.current) { + return; + } + setIsRailHovered(true); + }, []); + + const exitSafeZone = useCallback(() => { + safeBlockedRef.current = false; + safePolyRef.current = null; + if (navListRef.current) { + navListRef.current.style.pointerEvents = ''; + } + }, []); + + const handleRailMouseLeave = useCallback(() => { + peekSuppressedRef.current = false; + setIsRailHovered(false); + setHoveredCategory(null); + setIsCreateHovered(false); + lastPointerRef.current = null; + exitSafeZone(); + }, [exitSafeZone]); + + // --- Prediction cone via pointer-events blocking ----------------------- + // `commitPreview` maps a rail trigger key to the panel preview it shows. + const commitPreview = useCallback((key: string) => { + if (key === 'create') { + setIsCreateHovered(true); + return; + } + setIsCreateHovered(false); + setHoveredCategory(key as SidebarCategoryId); + }, []); + + const enterSafeZone = useCallback((x: number, y: number) => { + const panel = panelRef.current?.getBoundingClientRect(); + if (!panel || panel.width < 8) { + return; + } + // Triangle from the pointer to the panel's near (left) edge, padded + // vertically. While the pointer stays inside it the tabs are inert. + safePolyRef.current = [ + [x, y], + [panel.left, panel.top - SAFE_ZONE_BUFFER], + [panel.left, panel.bottom + SAFE_ZONE_BUFFER], + ]; + safeBlockedRef.current = true; + if (navListRef.current) { + navListRef.current.style.pointerEvents = 'none'; + } + }, []); + + const pointInPolygon = ( + x: number, + y: number, + poly: Array<[number, number]>, + ): boolean => { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) { + const [xi, yi] = poly[i]; + const [xj, yj] = poly[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; + }; + + // Enter the safe zone when the pointer leaves the *active* trigger heading + // toward the panel (rightward). Pointer blocking then takes over. + const handlePreviewLeave = useCallback( + (key: string, event: React.MouseEvent) => { + if (safeBlockedRef.current) { + return; + } + const activeKey = isCreateHovered ? 'create' : activeCategory; + if (key !== activeKey) { + return; + } + const prev = lastPointerRef.current; + // Heading rightward off the active trigger = arcing toward the panel. + // enterSafeZone no-ops when the panel isn't actually open. + if (prev && event.clientX - prev.x > 0) { + enterSafeZone(event.clientX, event.clientY); + } + }, + [isCreateHovered, activeCategory, enterSafeZone], + ); + + const handleRailMouseMove = useCallback( + (event: React.MouseEvent) => { + lastPointerRef.current = { x: event.clientX, y: event.clientY }; + if (!safeBlockedRef.current) { + return; + } + const panel = panelRef.current?.getBoundingClientRect(); + if (!panel) { + exitSafeZone(); + return; + } + const { clientX: x, clientY: y } = event; + const overPanel = + x >= panel.left && + x <= panel.right && + y >= panel.top && + y <= panel.bottom; + if (overPanel) { + // Reached the panel — keep the current preview, release the block. + exitSafeZone(); + return; + } + if (safePolyRef.current && pointInPolygon(x, y, safePolyRef.current)) { + return; + } + // Left the safe zone without reaching the panel — honour the trigger + // the pointer actually landed on. + exitSafeZone(); + const el = document.elementFromPoint(x, y) as HTMLElement | null; + const trigger = el?.closest('[data-sidebar-preview]'); + const key = trigger?.getAttribute('data-sidebar-preview'); + if (key) { + commitPreview(key); + } + }, + [exitSafeZone, commitPreview], + ); + + useEffect(() => () => exitSafeZone(), [exitSafeZone]); + const renderCategorySection = (category: SidebarCategoryId): ReactElement => { if (category === SidebarCategory.Squads) { return ( @@ -472,15 +1066,6 @@ export const SidebarDesktopV2 = ({ ); } - if (category === SidebarCategory.Discover) { - return ( - - ); - } if (category === SidebarCategory.Settings) { return ( ); } - if (category === SidebarCategory.Profile) { - return ( - <> -
- -
- - - ); - } - return ( <> + + ) => { + if (!isCollapsedHoverMode) { + return; + } + const target = event.target as HTMLElement; + if ( + target.closest( + 'a, button, input, select, textarea, [role="tab"], [role="button"], [role="menuitem"]', + ) + ) { + return; + } + onToggleExpanded(); + }; + + const renderCategoryTab = ( + categoryId: SidebarCategoryId, + ): ReactElement | null => { + const category = sidebarCategories.find((entry) => entry.id === categoryId); + if (!category) { + return null; + } + const isSelected = activeCategory === category.id; + return ( + + + + ); + }; + + const renderMorePanel = (): ReactElement => { + const rows = foldedNavIds.map((id) => { + if (id === SidebarCategory.Notifications) { + return { + key: id as string, + label: 'Notifications', + href: `${webappUrl}notifications`, + icon: , + }; + } + const category = sidebarCategories.find((entry) => entry.id === id); + return { + key: id as string, + label: category?.label ?? '', + href: category?.defaultPath ?? webappUrl, + icon: category?.icon(false) ?? null, + }; + }); + return ( +
+ {rows.map((row) => ( + + + + {row.icon} + + {row.label} + + + ))} +
+ ); + }; + + const createMenuItems = useMemo( + () => + createMenuOptions.map(({ title, kind, icon }) => ({ + icon, + title, + // SidebarItem/ClickableNavItem dispatches `action` (not `onClick`) and + // requires a `path` for link items — a path-less `onClick` row throws. + action: () => + openModal({ + type: LazyModal.SmartComposer, + props: { initialKind: kind }, + }), + })), + [openModal], + ); + + // The panel reflects the create-post options when hovering "+", otherwise + // the active category (hovered preview, else the selected/pinned one). const renderSelectedSection = (): ReactElement => - renderCategorySection(selectedCategory); + isCreateHovered ? ( +
+ ) : ( + renderCategorySection(activeCategory) + ); - const selectedLabel = sidebarCategories.find( - (category) => category.id === selectedCategory, + const activeLabel = sidebarCategories.find( + (category) => category.id === activeCategory, )?.label; - const isSettingsSelected = selectedCategory === SidebarCategory.Settings; const isNotificationsSelected = - selectedCategory === SidebarCategory.Notifications; - const isHomePanel = selectedCategory === SidebarCategory.Main; - const isSquadsPanel = selectedCategory === SidebarCategory.Squads; + activeCategory === SidebarCategory.Notifications; + const isHomePanel = + !isCreateHovered && activeCategory === SidebarCategory.Main; const isUtilityPanelSelected = !isHomePanel; const utilityPanelTitle = (() => { - if (isSettingsSelected) { + if (isCreateHovered) { + return 'New post'; + } + if (activeCategory === SidebarCategory.Settings) { return 'Settings'; } if (isNotificationsSelected) { return 'Notifications'; } - return selectedLabel ?? ''; + return activeLabel ?? ''; })(); - // Settings pages render their navigation only inside this context panel, so - // a collapsed sidebar would leave no way to move between settings sections. - // Force the panel open and hide the collapse toggle while on settings — - // without touching the user's stored `sidebarExpanded` preference, so the - // sidebar returns to its chosen state once they navigate away. - const forceExpanded = isSettingsSelected; - const isExpanded = sidebarExpanded || forceExpanded; + // Single-section panels (Squads/Saved) host their "+" add action in the panel + // title strip — next to the title — rather than as a row inside the section. + const panelAddAction = (() => { + if (isCreateHovered) { + return null; + } + if (activeCategory === SidebarCategory.Squads) { + return { + label: 'New Squad', + onClick: () => openNewSquad({ origin: Origin.Sidebar }), + }; + } + if (activeCategory === SidebarCategory.Saved) { + return { label: 'New folder', onClick: addBookmarkFolder }; + } + return null; + })(); return ( - {isExpanded && ( + {isExpanded && !isSettingsSelected && (
+ + )} {/* - Slide-between-anchors toggle button. Two positions: - - Open (panel right edge): ghost, no border/bg/shadow - - Closed (rail/panel boundary): bordered chip with shadow - Same SidebarArrowLeft glyph, rotated 180° when closed. + Slide-between-anchors toggle button. It tracks the *visible* right edge + (`isExpanded`) so it follows the panel when peeking and never collides + with the panel title. Its glyph/label reflect the *pinned* state + (`sidebarExpanded`) — i.e. what a click does: pin open vs collapse. + - Pinned open: ghost chip, arrow points left ("Close sidebar"). + - Collapsed: bordered chip, arrow points right ("Open sidebar"). Hidden on settings pages, where the panel is force-expanded and collapsing it would hide the only settings navigation. */} {!forceExpanded && ( + {sidebarExpanded ? 'Close sidebar' : 'Open sidebar'} + + [ + + + } collisionPadding={RAIL_TOOLTIP_COLLISION_PADDING} > + ); +}; + +// A side-by-side comparison of earn-pop speeds + easings. Independent inline +// keyframes (see POP_CSS) so the timing is accurate regardless of the tailwind +// build state. Use the Replay button to retrigger. +export const EarnPopVariations: Story = { + argTypes: { + state: { table: { disable: true } }, + count: { table: { disable: true } }, + hasReadToday: { table: { disable: true } }, + isLoading: { table: { disable: true } }, + chipTooltip: { table: { disable: true } }, + chipTooltipOpen: { table: { disable: true } }, + }, + render: () => , +}; + +// Every state side by side — change the count control to see how each handles a +// long number (the flame + count break a wider gap in the bottom border). +export const AllStates: Story = { + argTypes: { + state: { table: { disable: true } }, + hasReadToday: { table: { disable: true } }, + }, + render: ({ count }) => ( +
+ {STATES.map((state) => ( +
+ } + /> + + {STATE_LABEL[state]} + +
+ ))} +
+ ), +}; diff --git a/packages/storybook/svg.d.ts b/packages/storybook/svg.d.ts new file mode 100644 index 00000000000..ac9abe09131 --- /dev/null +++ b/packages/storybook/svg.d.ts @@ -0,0 +1,17 @@ +// Stories import shared components that pull in SVGR icon modules (`*.svg`). +// Mirror packages/shared/custom.d.ts so the storybook type-check can resolve +// them instead of failing with TS2307 (the SVGR webpack/vite loader handles the +// runtime; this is just the type side). +type SvgrComponent = React.FC>; + +declare module '*.svg' { + const value: SvgrComponent; + export default value; +} + +declare module '*.css'; + +declare module '*.module.css' { + const classes: Record; + export default classes; +} diff --git a/packages/webapp/components/layouts/SettingsLayout/index.tsx b/packages/webapp/components/layouts/SettingsLayout/index.tsx index 85b0e5b8f36..fb075045537 100644 --- a/packages/webapp/components/layouts/SettingsLayout/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/index.tsx @@ -1,5 +1,6 @@ import type { PropsWithChildren, ReactElement, ReactNode } from 'react'; import React, { useContext, useEffect } from 'react'; +import classNames from 'classnames'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; import { generateQueryKey, @@ -161,13 +162,20 @@ export default function SettingsLayout({ )} - {/* v2 PageHeader strip slot. Rendered outside the `max-w-5xl` content - wrapper below so the strip spans the full floating-card width. - AccountPageContainer portals into this on laptop v2. */} + {/* v2 PageHeader strip slot. The header spans the full floating-card + width (the v2 layout guideline) — only the content column below is + capped at 768px. AccountPageContainer portals into this on laptop v2. */} {isV2Laptop && (
)} -
+

Settings

{isMobile ? ( { const jsonLd = getJsonLd(); + const { isV2 } = useLayoutVariant(); return ( - - -