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..e6aa9e709cf 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -301,19 +301,25 @@ function MainLayoutComponent({ /> )}
{ +}: Props): ReactElement | null => { const { user } = useAuthContext(); const { isPlus } = usePlusSubscription(); + if (!user) { + return null; + } + return ( -
+
)} 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/Compose/filled.svg b/packages/shared/src/components/icons/Compose/filled.svg new file mode 100644 index 00000000000..00edd46e4be --- /dev/null +++ b/packages/shared/src/components/icons/Compose/filled.svg @@ -0,0 +1,4 @@ + +Icon/Compose/Filled + + diff --git a/packages/shared/src/components/icons/Compose/index.tsx b/packages/shared/src/components/icons/Compose/index.tsx new file mode 100644 index 00000000000..b74ceb92f03 --- /dev/null +++ b/packages/shared/src/components/icons/Compose/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 ComposeIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Compose/outlined.svg b/packages/shared/src/components/icons/Compose/outlined.svg new file mode 100644 index 00000000000..22afb48bea1 --- /dev/null +++ b/packages/shared/src/components/icons/Compose/outlined.svg @@ -0,0 +1,4 @@ + +Icon/Compose/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..93e33ffac6f --- /dev/null +++ b/packages/shared/src/components/icons/Help/filled.svg @@ -0,0 +1,4 @@ + +Icon/Help/Filled + + 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..d23deb8399f --- /dev/null +++ b/packages/shared/src/components/icons/Help/outlined.svg @@ -0,0 +1,4 @@ + +Icon/Help/Outline + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 37ba3f92871..571b4b789fc 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -32,6 +32,7 @@ export * from './Codeberg'; export * from './CodePen'; export * from './Coin'; export * from './CommunityPicksIcon'; +export * from './Compose'; export * from './Cookie'; export * from './Copy'; export * from './Core'; @@ -72,6 +73,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'; diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx index 0410d53f747..5d091bb3737 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'; @@ -12,7 +13,7 @@ import useSidebarRendered from '../../hooks/useSidebarRendered'; import Link from '../utilities/Link'; export interface SectionCommonProps - extends Pick { + extends Pick { sidebarExpanded: boolean; activePage: string; className?: string; @@ -37,6 +38,7 @@ export function Section({ isItemsButton, className, flag, + compact, isAlwaysOpenOnMobile, onAdd, addHref, @@ -65,7 +67,7 @@ export function Section({ }; return ( - + {title && ( {/* Divider shown when sidebar is collapsed */} @@ -84,7 +86,10 @@ export function Section({ // with the items below it (items have `mx-3`), so "Feeds v" // and the feed entries share the same x. Without this the // header was indented less than the items. - 'group/section ml-3 mr-2 flex min-h-9 flex-1 items-center justify-between py-1.5 pl-1 transition-opacity duration-300', + 'ml-3 mr-2 flex min-h-9 flex-1 items-center justify-between py-1.5 pl-1 transition-opacity duration-300', + // v2 compact: tighter, left-aligned header to match the smaller + // nav rows (mx-2) and Linear's denser list. + compact && '!ml-2 !min-h-6 !py-0.5', sidebarExpanded ? 'opacity-100' : 'pointer-events-none opacity-0', )} > @@ -94,20 +99,33 @@ export function Section({ aria-label={`Toggle ${title}`} aria-expanded={!!isVisible.current} aria-controls={flag ? `section-${flag}` : undefined} - className="flex items-center gap-1 rounded-6 px-1 py-0.5 transition-colors hover:bg-surface-hover hover:text-text-primary" + className="group/toggle flex items-center gap-1 rounded-6 px-1 py-0.5 transition-colors hover:bg-surface-hover hover:text-text-primary" > {title} @@ -143,7 +161,7 @@ export function Section({ : 'grid-rows-[0fr] opacity-0', )} > -
+
{items.map((item) => ( ))}
diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index e25c98ceab1..0130e222afc 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -1,286 +1,207 @@ import classNames from 'classnames'; import type { ReactElement, ReactNode } from 'react'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; -import * as HoverCardPrimitive from '@radix-ui/react-hover-card'; -import { Nav, SidebarAside, SidebarScrollWrapper } from './common'; -import { getSidebarCategoryForPath, SidebarCategory } from './sidebarCategory'; -import type { SidebarCategoryId } from './sidebarCategory'; +import { Nav, SidebarAside, SidebarScrollWrapper, ListIcon } from './common'; +import type { SidebarMenuItem } from './common'; +import { Section } from './Section'; +import { isSidebarSettingsPath } from './sidebarCategory'; import { ThemeMode, useSettingsContext } from '../../contexts/SettingsContext'; import { useLogContext } from '../../contexts/LogContext'; import { useBanner } from '../../hooks/useBanner'; -import { MainSection } from './sections/MainSection'; +import { PinnedSection } from './sections/PinnedSection'; import { CustomFeedSection } from './sections/CustomFeedSection'; -import { DiscoverSection } from './sections/DiscoverSection'; -import { ProfileSection } from './sections/ProfileSection'; -import { SidebarProfileCompletion } from './SidebarProfileCompletion'; -import { SettingsPanelSection } from './sections/SettingsPanelSection'; -import { CreatePostButton } from '../post/write'; -import { QuestRailIcon } from '../quest/QuestRailIcon'; -import { useClaimableQuestCount } from '../../hooks/useQuestDashboard'; -import { Bubble } from '../tooltips/utils'; -import { ButtonSize } from '../buttons/Button'; import { BookmarkSection } from './sections/BookmarkSection'; import { NetworkSection } from './sections/NetworkSection'; -import { GameCenterSection } from './sections/GameCenterSection'; +import { SettingsPanelSection } from './sections/SettingsPanelSection'; +import { useClaimableQuestCount } from '../../hooks/useQuestDashboard'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { HelpWidget } from '../help/HelpWidget'; import { - BookmarkIcon, - FeedbackIcon, + AnalyticsIcon, + AppIcon, + BellIcon, + BrowserGroupIcon, + ComposeIcon, + CreditCardIcon, + DevCardIcon, + DevPlusIcon, + DocsIcon, + ExitIcon, + EyeIcon, + FlagIcon, + HelpIcon, HomeIcon, HotIcon, + InviteIcon, + JobIcon, + JoystickIcon, + MagicIcon, + MegaphoneIcon, MoonIcon, - PlusIcon, + MoveToIcon, + PhoneIcon, + PrivacyIcon, SearchIcon, SettingsIcon, - SidebarArrowLeft, - SourceIcon, + SquadIcon, SunIcon, - UserIcon, + TerminalIcon, + TrendingIcon, } from '../icons'; -import { ThemeAutoIcon } from '../icons/ThemeAuto'; -import { useSquadNavigation } from '../../hooks'; -import { LogEvent, Origin, TargetType } from '../../lib/log'; +import { usePlusSubscription } from '../../hooks/usePlusSubscription'; +import { LogEvent, TargetId, TargetType } from '../../lib/log'; import { IconSize } from '../Icon'; import { Tooltip } from '../tooltip/Tooltip'; -import { RailHoverPanel } from './RailHoverPanel'; import { useSpotlight } from '../spotlight/SpotlightContext'; import { useAuthContext } from '../../contexts/AuthContext'; -import NotificationsBell from '../notifications/NotificationsBell'; -import { NotificationsRailPanel } from '../notifications/NotificationsRailPanel'; +import { useNotificationContext } from '../../contexts/NotificationsContext'; import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; -import { SidebarHeaderStats } from './SidebarHeaderStats'; +import { SidebarProfileStats } from './SidebarProfileStats'; +import { SidebarStreakButton } from './SidebarStreakButton'; import Link from '../utilities/Link'; -import { settingsUrl, webappUrl } from '../../lib/constants'; -import { isAppleDevice } from '../../lib/func'; +import { + appsUrl, + businessWebsiteUrl, + docs, + downloadBrowserExtension, + feedback, + privacyPolicy, + settingsUrl, + termsOfService, + webappUrl, +} from '../../lib/constants'; +import { isAppleDevice, isExtension } from '../../lib/func'; +import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; +import { OtherFeedPage } from '../../lib/query'; +import { SharedFeedPage, HorizontalSeparator } from '../utilities'; import LogoIcon from '../../svg/LogoIcon'; import InteractivePopup, { InteractivePopupPosition, } from '../tooltips/InteractivePopup'; import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; -import { ResourceSection } from '../ProfileMenu/sections/ResourceSection'; -import { ProfileMenuFooter } from '../ProfileMenu/ProfileMenuFooter'; +import { ProfileSection as ProfileMenuSection } from '../ProfileMenu/ProfileSection'; +import type { ProfileSectionItemProps } from '../ProfileMenu/ProfileSectionItem'; +import { ProfileMenuHeader } from '../ProfileMenu/ProfileMenuHeader'; +import { UpgradeToPlus } from '../UpgradeToPlus'; +import { LogoutReason } from '../../lib/user'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { LazyModal } from '../modals/common/types'; +import { useCanPurchaseCores } from '../../hooks/useCoresFeature'; import { FeedbackWidget } from '../feedback'; -import { HorizontalSeparator } from '../utilities'; -import { Typography, TypographyType } from '../typography/Typography'; - -type SidebarCategoryConfig = { - id: SidebarCategoryId; - label: string; - icon: (active: boolean) => ReactElement; - defaultPath?: string; -}; - -const sidebarCategories: SidebarCategoryConfig[] = [ - { - id: SidebarCategory.Main, - label: 'Home', - defaultPath: webappUrl, - icon: (active) => ( - - ), - }, - { - id: SidebarCategory.Squads, - label: 'Squads', - defaultPath: `${webappUrl}squads/discover`, - icon: (active) => ( - - ), - }, - { - 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', - // 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 - // via the hover panel. - defaultPath: `${webappUrl}daily-quests`, - icon: (active) => , - }, - { - id: SidebarCategory.Profile, - label: 'Profile', - icon: (active) => ( - - ), - }, -]; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; -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'; const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K']; +// Sidebar collapse/expand shortcut (plain bracket key, like Linear). +const sidebarToggleShortcut = '['; 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; - -interface RailHoverCardProps { - label: string; - children: ReactNode; - panel: ReactElement; - enabled?: boolean; - alignOffset?: number; -} - -const RailHoverCard = ({ - label, - children, - panel, - enabled = true, - alignOffset, -}: RailHoverCardProps) => { - // Controlled open + suppression flag: after a click on the trigger we - // close the panel and block reopens until the pointer actually leaves - // the trigger. Otherwise Radix's openDelay timer re-fires while the - // cursor still rests on the just-clicked item and the panel pops back - // up after navigation. - const [open, setOpen] = useState(false); - const suppressOpenRef = useRef(false); - - const handleOpenChange = useCallback((next: boolean) => { - if (next && suppressOpenRef.current) { - return; - } - setOpen(next); - }, []); - - const handleTriggerClick = useCallback(() => { - suppressOpenRef.current = true; - setOpen(false); - }, []); - - const handleTriggerPointerLeave = useCallback(() => { - suppressOpenRef.current = false; - }, []); - - if (!enabled) { - return <>{children}; - } - return ( - - - {children} - - - - {panel} - - - - ); -}; +// Resizable panel bounds (px). 304px = 19rem keeps the default in step with +// the `var(--sidebar-width, 19rem)` fallback used before settings load. +const SIDEBAR_DEFAULT_WIDTH = 304; +const SIDEBAR_MIN_WIDTH = 240; +const SIDEBAR_MAX_WIDTH = 420; +// Collapsed icon-rail width (px) — must match the `w-14` class + MainLayout. +const SIDEBAR_RAIL_WIDTH = 56; +// Pulling the open panel narrower than this collapses it to the rail. +const SIDEBAR_COLLAPSE_AT = 180; -const themeIconMap: Record< - ThemeMode, - React.ComponentType<{ - secondary?: boolean; - size?: IconSize; - 'aria-hidden'?: boolean; - }> -> = { - [ThemeMode.Dark]: MoonIcon, - [ThemeMode.Light]: SunIcon, - [ThemeMode.Auto]: ThemeAutoIcon, -}; +// Compact square icon button (Linear-sized: 32px hit area, 16px glyph) shared +// by the header search and the footer support controls. +const iconButtonClass = + 'focus-outline flex size-8 items-center justify-center rounded-10 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary [&_svg]:!size-4'; 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 Icon = isDark ? SunIcon : MoonIcon; - const onToggleTheme = useCallback(() => { + const onToggle = () => { + const nextMode = isDark ? ThemeMode.Light : ThemeMode.Dark; logEvent({ event_name: LogEvent.ChangeSettings, target_type: TargetType.Theme, target_id: nextMode, }); setTheme(nextMode); - }, [logEvent, nextMode, setTheme]); + }; return ( ); }; +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 }, +]; + +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(); return ( <> - + {isOpen && ( @@ -291,9 +212,163 @@ const SidebarSupportButton = (): ReactElement => { className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" > - + - + + + )} + + ); +}; + +// Profile switcher in the sidebar header (avatar + name), opening a curated +// menu built from the shared ProfileSection rows: reputation/cores stats, +// account links, and log out. +const SidebarProfileButton = ({ + iconOnly = false, +}: { + iconOnly?: boolean; +} = {}): ReactElement | null => { + const { user, logout } = useAuthContext(); + const { isPlus } = usePlusSubscription(); + const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); + const { openModal } = useLazyModal(); + const canPurchaseCores = useCanPurchaseCores(); + + if (!user) { + return null; + } + + const mainItems: ProfileSectionItemProps[] = [ + { + title: 'Your profile', + href: `${webappUrl}${user.username}`, + icon: EyeIcon, + }, + { 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: 'Feed settings', + href: `${settingsUrl}/feed/general`, + icon: AppIcon, + }, + ]; + + const logoutItems: ProfileSectionItemProps[] = [ + { + title: 'Log out', + icon: ExitIcon, + onClick: () => logout(LogoutReason.ManualLogout), + }, + ]; + + return ( + <> + + {isOpen && ( + onUpdate(false)} + position={InteractivePopupPosition.SidebarProfileMenu} + className="flex max-h-[calc(100dvh-6rem)] w-72 flex-col gap-3 overflow-y-auto !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" + > + + + + + + + + + + + )} @@ -328,47 +403,28 @@ export const SidebarDesktopV2 = ({ toggleSidebarExpanded, loadedSettings, optOutQuestSystem, + flags, + updateFlag, } = useSettingsContext(); const { logEvent } = useLogContext(); const { isAvailable: isBannerAvailable } = useBanner(); const { open: openSpotlight } = useSpotlight(); - const { openNewSquad } = useSquadNavigation(); - const { isLoggedIn, user } = useAuthContext(); + const { isLoggedIn } = useAuthContext(); + const { openModal } = useLazyModal(); + const { unreadCount } = useNotificationContext(); + const { isCustomDefaultFeed } = useCustomDefaultFeed(); const claimableQuestCount = useClaimableQuestCount(); - const showQuestBadge = !optOutQuestSystem && claimableQuestCount > 0; + const showQuests = isLoggedIn && !optOutQuestSystem; 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]); - // 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; + // Settings pages render their navigation only inside this panel, so a + // collapsed sidebar would strand them with no way to move between sections. + // Force it open there without touching the stored preference. + const forceExpanded = isSidebarSettingsPath(activePage); - useEffect(() => { - if (pendingCategory !== null && pendingCategory === resolvedCategory) { - setPendingCategory(null); - } - }, [pendingCategory, resolvedCategory]); - - // Settings load client-side, so on a hard refresh `sidebarExpanded` - // flips from its `false` default to the user's stored value once - // `loadedSettings` resolves. The width/opacity transitions below would - // animate that initial settle, making the sidebar appear to slide/fade - // in. Keep transitions off until settings have loaded so the sidebar - // snaps into its final state on first paint and only genuine user + // Settings load client-side, so on a hard refresh `sidebarExpanded` settles + // from its `false` default to the stored value. Keep transitions off until + // settings load so the panel snaps into place on first paint and only real // toggles animate afterwards. const [transitionsEnabled, setTransitionsEnabled] = useState(false); useEffect(() => { @@ -380,557 +436,663 @@ export const SidebarDesktopV2 = ({ ? undefined : '!transition-none'; - // Escape resets the pinned panel back to Main so the keyboard story - // mirrors the click model — Tab+Enter opens a panel, Escape backs out. - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setPendingCategory(SidebarCategory.Main); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, []); + // While dragging the rail grip we preview the open panel (full layout) before + // the open state is committed on release — so the icons jump to the left and + // labels fade in, instead of the rail growing with centered icons. + const [railPreview, setRailPreview] = useState(false); - const defaultRenderSectionProps = useMemo( - () => ({ - sidebarExpanded: true, - shouldShowLabel: true, - activePage, - }), - [activePage], - ); + // Pinned-open vs collapsed. Collapsed isn't hidden — it's a persistent + // icon-only rail (production-style); hovering it does NOT open the panel. + // The panel only opens when pinned (resize grip / "[" / feed-header button). + // Settings force the full panel open. `isPanelOpen` = labels + full layout + // (also true mid drag-to-open preview, before the open state commits). + const isExpanded = sidebarExpanded || forceExpanded; + const isPanelOpen = isExpanded || railPreview; - const getCategoryDefaultPath = useCallback( - (category: SidebarCategoryId): string | null => { - if (category === SidebarCategory.Profile) { - return user?.username ? `${webappUrl}${user.username}` : null; - } - if (category === SidebarCategory.Settings) { - return settingsDefaultPath; - } - return ( - sidebarCategories.find((entry) => entry.id === category)?.defaultPath ?? - null - ); - }, - [user?.username], + // The panel width is resizable and persisted. The live value lives in the + // `--sidebar-width` CSS variable so the sidebar and the main content (which + // pads by the same variable in MainLayout) resize together, 1:1, while + // dragging — without re-rendering on every pointer move. + const resolvedWidth = Math.min( + SIDEBAR_MAX_WIDTH, + Math.max(SIDEBAR_MIN_WIDTH, flags?.sidebarWidth ?? SIDEBAR_DEFAULT_WIDTH), ); - const onSelectCategory = useCallback( - (category: SidebarCategoryId) => { - setPendingCategory(category); + useEffect(() => { + document.documentElement.style.setProperty( + '--sidebar-width', + `${resolvedWidth}px`, + ); + }, [resolvedWidth, sidebarExpanded]); - // Click navigates to the category's first sub-page (its - // `defaultPath`) — it no longer auto-expands the sidebar. The - // sidebar's open/closed state is controlled solely by the user - // via the dedicated toggle button. - const targetPath = getCategoryDefaultPath(category); - if (!targetPath) { + // Collapse/expand shortcut: the plain "[" key (like Linear). Ignored with + // modifiers (so ⌘/Ctrl+[ stays browser "back") and while typing. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey) { return; } - const targetPathname = new URL(targetPath, 'http://_').pathname; - const currentPathname = activePage.split('?')[0]; - if (targetPathname !== currentPathname) { - // `Promise.resolve` wraps the call so `.catch` still works when - // the next/router test mock returns `undefined` from `push`. - Promise.resolve(router.push(targetPath)).catch(() => undefined); + if (event.key !== '[') { + return; } + const target = event.target as HTMLElement | null; + if ( + target?.closest('input, textarea, select, [contenteditable="true"]') + ) { + return; + } + event.preventDefault(); + logEvent({ + event_name: `${sidebarExpanded ? 'close' : 'open'} sidebar`, + }); + toggleSidebarExpanded(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [logEvent, sidebarExpanded, toggleSidebarExpanded]); + + // Highlights the resize grip while actively dragging (toggled at drag + // start/end only — never on pointer move, so it doesn't re-render mid-drag). + const [isResizing, setIsResizing] = useState(false); + + // Once the open state actually commits, drop the preview flag. Keeping the + // preview on until `isExpanded` flips avoids a one-frame collapse flash + // between releasing the drag and the settings update landing. + useEffect(() => { + if (isExpanded) { + setRailPreview(false); + } + }, [isExpanded]); + + // Grip drag from the COLLAPSED rail: previews the open panel as soon as the + // pointer moves (full layout animates in, icons stay left — the rail does + // NOT grow with centered icons), then commits open on release. A plain click + // opens too. Dragging back over the rail cancels. + const onRailGripMouseDown = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + const root = document.documentElement; + const startX = event.clientX; + let lastCursorX: number | null = null; + let moved = false; + document.body.style.userSelect = 'none'; + + const onMove = (moveEvent: MouseEvent) => { + lastCursorX = moveEvent.clientX; + if (!moved && Math.abs(moveEvent.clientX - startX) > 6) { + moved = true; + // Snap straight to the open width and let the CSS transition animate + // the expand (transitions stay enabled — this isn't a 1:1 resize). + root.style.setProperty('--sidebar-width', `${resolvedWidth}px`); + setRailPreview(true); + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.userSelect = ''; + + // Dragged back over the rail → cancel, stay collapsed. + if (moved && lastCursorX != null && lastCursorX < SIDEBAR_RAIL_WIDTH) { + setRailPreview(false); + return; + } + root.style.setProperty('--sidebar-width', `${resolvedWidth}px`); + logEvent({ event_name: 'open sidebar' }); + toggleSidebarExpanded(); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); }, - [activePage, getCategoryDefaultPath, router], + [resolvedWidth, logEvent, toggleSidebarExpanded], ); - const onPrefetchCategory = useCallback( - (category: SidebarCategoryId) => { - const targetPath = getCategoryDefaultPath(category); - if (!targetPath) { + // Grip drag from the OPEN panel: live 1:1 resize; pull past the collapse + // threshold to close. Transitions are suppressed only during the active + // drag so the panel tracks the cursor exactly. + const onResizeHandleMouseDown = useCallback( + (event: React.MouseEvent) => { + if (!isExpanded) { + onRailGripMouseDown(event); return; } - router.prefetch(targetPath).catch(() => undefined); + event.preventDefault(); + const root = document.documentElement; + let lastCursorX: number | null = null; + setIsResizing(true); + root.classList.add('sidebar-resizing'); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMove = (moveEvent: MouseEvent) => { + lastCursorX = moveEvent.clientX; + const width = Math.min( + SIDEBAR_MAX_WIDTH, + Math.max(SIDEBAR_MIN_WIDTH, moveEvent.clientX), + ); + root.style.setProperty('--sidebar-width', `${width}px`); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + setIsResizing(false); + root.classList.remove('sidebar-resizing'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + if (lastCursorX == null) { + return; + } + if (lastCursorX < SIDEBAR_COLLAPSE_AT) { + logEvent({ event_name: 'close sidebar' }); + toggleSidebarExpanded(); + return; + } + const width = Math.min( + SIDEBAR_MAX_WIDTH, + Math.max(SIDEBAR_MIN_WIDTH, lastCursorX), + ); + root.style.setProperty('--sidebar-width', `${width}px`); + updateFlag('sidebarWidth', width); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); }, - [getCategoryDefaultPath, router], + [ + isExpanded, + onRailGripMouseDown, + logEvent, + toggleSidebarExpanded, + updateFlag, + ], ); - const onToggleExpanded = useCallback(() => { - logEvent({ - event_name: `${sidebarExpanded ? 'open' : 'close'} sidebar`, - }); - toggleSidebarExpanded(); - }, [logEvent, sidebarExpanded, toggleSidebarExpanded]); + const defaultRenderSectionProps = useMemo( + () => ({ + // When the panel is open, sections show titles + labels; collapsed to + // the rail they become icon-only with tooltips and divider headers. + sidebarExpanded: isPanelOpen, + shouldShowLabel: isPanelOpen, + activePage, + // Compact (Linear-style) rows + section headers for the single panel. + compact: true, + }), + [activePage, isPanelOpen], + ); - const renderCategorySection = (category: SidebarCategoryId): ReactElement => { - if (category === SidebarCategory.Squads) { - return ( - - ); - } - if (category === SidebarCategory.Saved) { - return ( - - ); - } - if (category === SidebarCategory.Discover) { - return ( - - ); - } - if (category === SidebarCategory.Settings) { - return ( - - ); - } - if (category === SidebarCategory.Notifications) { - return ; - } - if (category === SidebarCategory.GameCenter) { - return ( - - ); - } - if (category === SidebarCategory.Profile) { - return ( - <> -
- -
- - - ); + const countBadge = useCallback( + (count: number): SidebarMenuItem['rightIcon'] => + count > 0 + ? () => ( + + {count > 99 ? '99+' : count} + + ) + : undefined, + [], + ); + + // The primary nav, in an explicit (v2-only) order: + // For You, Following, Notifications, Quests, Explore, Happening Now, History. + // Built here rather than via the shared MainSection so the order — which + // interleaves Notifications/Quests with the feed items — stays v2-specific + // and doesn't affect the v1 sidebar. + const primaryItems: SidebarMenuItem[] = useMemo(() => { + // Mirror MainSection's "For You" target so navigation + active state match. + let myFeedPath = isCustomDefaultFeed ? '/my-feed' : '/'; + if (isExtension) { + myFeedPath = '/my-feed'; } - return ( - <> - - - - ); - }; + const items: (SidebarMenuItem | false)[] = [ + isLoggedIn + ? { + title: 'For You', + path: myFeedPath, + action: () => + onNavTabClick?.( + isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/', + ), + icon: (active: boolean) => ( + } /> + ), + } + : { + title: 'Home', + path: '/', + action: () => onNavTabClick?.('/'), + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Following', + path: '/following', + action: () => onNavTabClick?.(OtherFeedPage.Following), + requiresLogin: true, + icon: (active: boolean) => ( + } /> + ), + }, + isLoggedIn && { + title: 'Notifications', + path: `${webappUrl}notifications`, + isForcedLink: true, + icon: (active: boolean) => ( + } /> + ), + rightIcon: countBadge(unreadCount), + }, + showQuests && { + title: 'Quests', + path: `${webappUrl}game-center`, + isForcedLink: true, + icon: (active: boolean) => ( + } /> + ), + rightIcon: countBadge(claimableQuestCount), + }, + { + title: 'Explore', + path: '/posts', + action: () => onNavTabClick?.(OtherFeedPage.Explore), + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Happening Now', + path: `${webappUrl}highlights`, + isForcedLink: true, + requiresLogin: true, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'History', + path: `${webappUrl}history`, + isForcedLink: true, + requiresLogin: true, + icon: (active: boolean) => ( + } /> + ), + }, + ]; - const renderSelectedSection = (): ReactElement => - renderCategorySection(selectedCategory); - - const selectedLabel = sidebarCategories.find( - (category) => category.id === selectedCategory, - )?.label; - const isSettingsSelected = selectedCategory === SidebarCategory.Settings; - const isNotificationsSelected = - selectedCategory === SidebarCategory.Notifications; - const isHomePanel = selectedCategory === SidebarCategory.Main; - const isSquadsPanel = selectedCategory === SidebarCategory.Squads; - const isUtilityPanelSelected = !isHomePanel; - const utilityPanelTitle = (() => { - if (isSettingsSelected) { - return 'Settings'; - } - if (isNotificationsSelected) { - return 'Notifications'; - } - return selectedLabel ?? ''; - })(); - - // 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; + return items.filter(Boolean) as SidebarMenuItem[]; + }, [ + claimableQuestCount, + countBadge, + isCustomDefaultFeed, + isLoggedIn, + onNavTabClick, + showQuests, + unreadCount, + ]); return ( - {isExpanded && ( - - )} - - - {/* - 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. - Hidden on settings pages, where the panel is force-expanded and - collapsing it would hide the only settings navigation. - */} - {!forceExpanded && ( - - + + {/* Grip on the sidebar's right edge — always present (the resize + affordance), in both the rail and the open panel. Open: drag to + resize / pull past the threshold to collapse. Collapsed rail: drag to + pull open (or click). One mousedown handler covers click + drag, so + both states share a smooth transition. */} + {!forceExpanded && ( + + Drag to resize + + Toggle sidebar · {sidebarToggleShortcut} + + + ) : ( + + Drag or click to open + + Toggle sidebar · {sidebarToggleShortcut} + + + ) + } + > + +
+ )} + {/* Drag gesture hint: while previewing the open panel, tell the user that + releasing the cursor will keep it open. */} + {railPreview && !isExpanded && ( +
+ Release to open +
+ )} ); }; diff --git a/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx deleted file mode 100644 index 4b49af9e022..00000000000 --- a/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import type { MouseEvent, ReactElement, ReactNode } from 'react'; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from 'react'; -import classNames from 'classnames'; -import { useAuthContext } from '../../contexts/AuthContext'; -import { useReadingStreak } from '../../hooks/streaks'; -import { useLogContext } from '../../contexts/LogContext'; -import { - reputation as reputationDocsUrl, - walletUrl, -} from '../../lib/constants'; -import { largeNumberFormat } from '../../lib'; -import { formatCurrency } from '../../lib/utils'; -import { isSameDayInTimezone } from '../../lib/timezones'; -import { LogEvent } from '../../lib/log'; -import Link from '../utilities/Link'; -import { Tooltip } from '../tooltip/Tooltip'; -import { SimpleTooltip } from '../tooltips'; -import type { TooltipProps } from '../tooltips/BaseTooltip'; -import { RootPortal } from '../tooltips/Portal'; -import { IconSize } from '../Icon'; -import { CoreIcon, ReadingStreakIcon, ReputationIcon } from '../icons'; -import { Typography, TypographyType } from '../typography/Typography'; -import { ReadingStreakPopup } from '../streak/popup/ReadingStreakPopup'; -import type { UserStreak } from '../../graphql/users'; - -const slotClass = - 'focus-outline group flex flex-1 items-center justify-center gap-1 px-1.5 py-1.5 transition-colors hover:bg-surface-hover min-w-0'; -const valueClass = 'text-text-primary tabular-nums'; -const iconBoxClass = 'flex size-4 shrink-0 items-center justify-center'; - -type StatSlotProps = { - ariaLabel: string; - icon: ReactNode; - value: string | number | null; - href?: string; - target?: string; - onClick?: (event: MouseEvent) => void; - id?: string; -}; - -// Each slot is rendered inside a wrapper element that acts as the Tooltip -// trigger (see usage below). Keeping the anchor/button as a plain child — -// instead of making it the Radix `asChild` trigger directly — avoids the -// double clone (Radix Slot + Next `legacyBehavior` Link) that swallowed the -// link's click navigation. -const StatSlot = ({ - ariaLabel, - icon, - value, - href, - target, - onClick, - id, -}: StatSlotProps): ReactElement => { - const inner = ( - <> - {icon} - - {value} - - - ); - - if (onClick) { - return ( - - ); - } - - if (!href) { - return ( - - {inner} - - ); - } - - return ( - - - {inner} - - - ); -}; - -const dividerClass = 'w-px self-stretch bg-border-subtlest-quaternary'; - -type StreakPopoverProps = { - streak: UserStreak; - triggerRef: React.RefObject; - onClose: () => void; -}; - -// Manually positioned portal popover: read the trigger's bounding rect -// and render the panel directly below it via a body-level portal. This -// keeps the popover stable (no Tippy auto-flip surprises inside the -// sidebar's transform / overflow context) and ensures it always drops -// down from the streak button as expected. -const StreakPopover = ({ - streak, - triggerRef, - onClose, -}: StreakPopoverProps): ReactElement | null => { - const [position, setPosition] = useState<{ - top: number; - left: number; - } | null>(null); - const popoverRef = useRef(null); - - const updatePosition = useCallback(() => { - const trigger = triggerRef.current; - if (!trigger) { - return; - } - const rect = trigger.getBoundingClientRect(); - setPosition({ top: rect.bottom + 8, left: rect.left }); - }, [triggerRef]); - - useLayoutEffect(() => { - updatePosition(); - }, [updatePosition]); - - useEffect(() => { - window.addEventListener('resize', updatePosition); - window.addEventListener('scroll', updatePosition, true); - return () => { - window.removeEventListener('resize', updatePosition); - window.removeEventListener('scroll', updatePosition, true); - }; - }, [updatePosition]); - - useEffect(() => { - const handleClickOutside = (event: globalThis.MouseEvent) => { - const target = event.target as Node | null; - if ( - target && - !popoverRef.current?.contains(target) && - !triggerRef.current?.contains(target) - ) { - onClose(); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [onClose, triggerRef]); - - if (!position) { - return null; - } - - return ( - -
- -
-
- ); -}; - -const StreakHintTooltip = ({ - children, - content, -}: Pick): ReactElement => ( - - {children} - -); - -export const SidebarHeaderStats = (): ReactElement | null => { - const { user } = useAuthContext(); - const { streak, isStreaksEnabled } = useReadingStreak(); - const { logEvent } = useLogContext(); - const [isStreaksOpen, setIsStreaksOpen] = useState(false); - const streakSlotRef = useRef(null); - - const handleStreakClick = useCallback(() => { - setIsStreaksOpen((open) => { - const next = !open; - if (next) { - logEvent({ event_name: LogEvent.OpenStreaks }); - } - return next; - }); - }, [logEvent]); - - if (!user) { - return null; - } - - const reputation = user.reputation ?? 0; - const balance = user.balance?.amount ?? 0; - const showStreak = isStreaksEnabled; - const preciseBalance = formatCurrency(balance, { minimumFractionDigits: 0 }); - const streakValue = streak?.current ?? 0; - const hasReadToday = - !!streak?.lastViewAt && - isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone); - - const streakSlot = ( - - - - } - value={streakValue} - onClick={streak ? handleStreakClick : undefined} - /> - ); - - return ( -
- {showStreak && ( - <> -
- {isStreaksOpen ? ( - streakSlot - ) : ( - - {streakSlot} - - )} -
- {streak && isStreaksOpen && ( - setIsStreaksOpen(false)} - /> - )} - - - )} - -
- - - - } - value={largeNumberFormat(reputation)} - href={reputationDocsUrl} - target="_blank" - /> -
-
- - - Wallet -
- {preciseBalance} Cores - - } - side="bottom" - > -
- - - - } - value={largeNumberFormat(balance)} - href={walletUrl} - /> -
-
-
- ); -}; diff --git a/packages/shared/src/components/sidebar/SidebarItem.tsx b/packages/shared/src/components/sidebar/SidebarItem.tsx index a34fc05b80d..eba6c8c509d 100644 --- a/packages/shared/src/components/sidebar/SidebarItem.tsx +++ b/packages/shared/src/components/sidebar/SidebarItem.tsx @@ -12,7 +12,7 @@ import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; type SidebarItemProps = Pick< SidebarSectionProps, - 'activePage' | 'isItemsButton' | 'shouldShowLabel' + 'activePage' | 'isItemsButton' | 'shouldShowLabel' | 'compact' > & { item: SidebarMenuItem; }; @@ -22,6 +22,7 @@ export const SidebarItem = ({ activePage, isItemsButton, shouldShowLabel, + compact, }: SidebarItemProps): ReactElement => { const { user, showLogin } = useContext(AuthContext); const { isV2 } = useLayoutVariant(); @@ -36,7 +37,16 @@ export const SidebarItem = ({ color={item.color} disableDefaultBackground={item.disableDefaultBackground} className={classNames( - isV2 ? 'mx-3 rounded-10' : 'mx-1 rounded-10', + // eslint-disable-next-line no-nested-ternary + compact && isCollapsed + ? // Collapsed rail: a centered square button (1:1) in the w-14 rail. + 'mx-auto size-8 rounded-10' + : // eslint-disable-next-line no-nested-ternary + compact + ? 'mx-2 rounded-8' + : isV2 + ? 'mx-3 rounded-10' + : 'mx-1 rounded-10', item.itemClassName, isCollapsed && 'justify-center', )} @@ -50,11 +60,19 @@ export const SidebarItem = ({ : undefined } isButton={isItemsButton && !item?.isForcedLink} + className={classNames( + // Collapsed rail: fill the square + center the icon (override the + // row's height + label padding). Otherwise compact = 28px rows. + compact && isCollapsed + ? 'laptop:!size-8 laptop:!justify-center laptop:!p-0' + : compact && 'laptop:!h-7', + )} > diff --git a/packages/shared/src/components/sidebar/SidebarProfileStats.tsx b/packages/shared/src/components/sidebar/SidebarProfileStats.tsx new file mode 100644 index 00000000000..7b515173b74 --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarProfileStats.tsx @@ -0,0 +1,75 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { + reputation as reputationDocsUrl, + walletUrl, +} from '../../lib/constants'; +import { largeNumberFormat } from '../../lib'; +import { formatCurrency } from '../../lib/utils'; +import Link from '../utilities/Link'; +import { IconSize } from '../Icon'; +import { CoreIcon, ReputationIcon } from '../icons'; +import { Typography, TypographyType } from '../typography/Typography'; + +const cellClass = + 'focus-outline flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 transition-colors hover:bg-surface-hover'; + +// Reputation + Cores wallet shown at the top of the rail-bottom profile +// dropdown as one compact strip (icon + number, no labels). Streak is +// intentionally omitted here — it lives on the rail (see SidebarRailStats). +export const SidebarProfileStats = (): ReactElement | null => { + const { user } = useAuthContext(); + + if (!user) { + return null; + } + + const reputation = user.reputation ?? 0; + const balance = user.balance?.amount ?? 0; + const preciseBalance = formatCurrency(balance, { minimumFractionDigits: 0 }); + + return ( + + ); +}; diff --git a/packages/shared/src/components/sidebar/SidebarStreakButton.tsx b/packages/shared/src/components/sidebar/SidebarStreakButton.tsx new file mode 100644 index 00000000000..8a55338e30b --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarStreakButton.tsx @@ -0,0 +1,104 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useReadingStreak } from '../../hooks/streaks'; +import { useLogContext } from '../../contexts/LogContext'; +import { isSameDayInTimezone } from '../../lib/timezones'; +import { LogEvent } from '../../lib/log'; +import { Tooltip } from '../tooltip/Tooltip'; +import { IconSize } from '../Icon'; +import { ReadingStreakIcon } from '../icons'; +import { Typography, TypographyType } from '../typography/Typography'; +import { StreakPopover } from './StreakPopover'; + +// Compact reading-streak chip (icon + count) shown beside the profile in the +// sidebar header. Clicking opens the streak popover below it. In the collapsed +// rail (`iconOnly`) only the small flame shows — the count doesn't fit a 32px +// square and a long streak would overflow it. +export const SidebarStreakButton = ({ + iconOnly = false, +}: { + iconOnly?: boolean; +} = {}): ReactElement | null => { + const { user } = useAuthContext(); + const { streak, isStreaksEnabled } = useReadingStreak(); + const { logEvent } = useLogContext(); + const [isOpen, setIsOpen] = useState(false); + const triggerRef = useRef(null); + + const onClick = useCallback(() => { + setIsOpen((open) => { + const next = !open; + if (next) { + logEvent({ event_name: LogEvent.OpenStreaks }); + } + return next; + }); + }, [logEvent]); + + if (!user || !isStreaksEnabled) { + return null; + } + + const value = streak?.current ?? 0; + const hasReadToday = + !!streak?.lastViewAt && + isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone); + + const button = ( + + ); + + return ( +
+ {isOpen ? ( + button + ) : ( + + {button} + + )} + {streak && isOpen && ( + setIsOpen(false)} + placement="bottom" + /> + )} +
+ ); +}; diff --git a/packages/shared/src/components/sidebar/StreakPopover.tsx b/packages/shared/src/components/sidebar/StreakPopover.tsx new file mode 100644 index 00000000000..4aa8c8c357d --- /dev/null +++ b/packages/shared/src/components/sidebar/StreakPopover.tsx @@ -0,0 +1,106 @@ +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { RootPortal } from '../tooltips/Portal'; +import { ReadingStreakPopup } from '../streak/popup/ReadingStreakPopup'; +import type { UserStreak } from '../../graphql/users'; + +type StreakPopoverProps = { + streak: UserStreak; + triggerRef: React.RefObject; + onClose: () => void; + // 'bottom' drops below the trigger (panel/header usage). 'right' opens + // beside the trigger and anchors to its bottom edge so it grows upward — + // used by the rail cluster, which sits near the viewport bottom. + placement?: 'bottom' | 'right'; +}; + +// Manually positioned portal popover: read the trigger's bounding rect +// and render the panel via a body-level portal. This keeps the popover +// stable (no Tippy auto-flip surprises inside the sidebar's transform / +// overflow context) and ensures it always drops from the streak button as +// expected. +export const StreakPopover = ({ + streak, + triggerRef, + onClose, + placement = 'bottom', +}: StreakPopoverProps): ReactElement | null => { + const [position, setPosition] = useState<{ + top?: number; + left: number; + bottom?: number; + } | null>(null); + const popoverRef = useRef(null); + + const updatePosition = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) { + return; + } + const rect = trigger.getBoundingClientRect(); + if (placement === 'right') { + setPosition({ + left: rect.right + 8, + bottom: window.innerHeight - rect.bottom, + }); + return; + } + setPosition({ top: rect.bottom + 8, left: rect.left }); + }, [placement, triggerRef]); + + useLayoutEffect(() => { + updatePosition(); + }, [updatePosition]); + + useEffect(() => { + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [updatePosition]); + + useEffect(() => { + const handleClickOutside = (event: globalThis.MouseEvent) => { + const target = event.target as Node | null; + if ( + target && + !popoverRef.current?.contains(target) && + !triggerRef.current?.contains(target) + ) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose, triggerRef]); + + if (!position) { + return null; + } + + return ( + +
+ +
+
+ ); +}; diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index e25cf19874b..a24fe4d85c8 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -40,6 +40,10 @@ export interface ItemInnerProps { item: SidebarMenuItem; shouldShowLabel: boolean; active?: boolean; + // v2 single-panel sidebar: smaller icon + 13px label (Linear-style compact + // rows). Set by the parent list, not derived here, so the primitive stays + // context-free. + compact?: boolean; } interface NavItemProps { color?: string; @@ -122,16 +126,37 @@ export const ItemInner = ({ item, shouldShowLabel, active, + compact, }: ItemInnerProps): ReactElement => { const isLabelHidden = !shouldShowLabel; return ( <> - + ( + } /> + ), + title: 'Explore', + path: '/posts', + action: () => onNavTabClick?.(OtherFeedPage.Explore), + } + : undefined; + return ( [ myFeed, @@ -167,6 +181,7 @@ export const MainSection = ({ } /> ), }, + explore, { icon: (active: boolean) => ( } /> diff --git a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx index 52a4ed62b6f..5a27ebaf092 100644 --- a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx @@ -2,25 +2,26 @@ import type { ReactElement } from 'react'; import React, { useCallback, useMemo } from 'react'; import type { SidebarMenuItem } from '../common'; import { ListIcon } from '../common'; -import { DefaultSquadIcon, SourceIcon, TimerIcon } from '../../icons'; +import { SourceIcon, TimerIcon } from '../../icons'; import { Section } from '../Section'; import { Origin } from '../../../lib/log'; import { useSquadNavigation } from '../../../hooks'; import { useAuthContext } from '../../../contexts/AuthContext'; -import { SquadImage } from '../../squads/SquadImage'; import { SidebarSettingsFlags } from '../../../graphql/settings'; import { squadCategoriesPaths, webappUrl } from '../../../lib/constants'; import type { SidebarSectionProps } from './common'; import { useSquadPendingPosts } from '../../../hooks/squads/useSquadPendingPosts'; import { Typography, TypographyColor } from '../../typography/Typography'; import { SourcePostModerationStatus } from '../../../graphql/squads'; -import { SquadFavoriteButton } from '../../squads/SquadFavoriteButton'; +import { createSquadMenuItem } from './squadMenuItem'; +import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant'; export const NetworkSection = ({ isItemsButton, ...defaultRenderSectionProps }: SidebarSectionProps): ReactElement => { const { squads } = useAuthContext(); + const { isV2 } = useLayoutVariant(); const { openNewSquad } = useSquadNavigation(); const { count, isModeratorInAnySquad } = useSquadPendingPosts({ status: [SourcePostModerationStatus.Pending], @@ -31,22 +32,19 @@ export const NetworkSection = ({ }, [openNewSquad]); const menuItems: SidebarMenuItem[] = useMemo(() => { + // v2 pins live in the Home → Pinned section, so the Squads list no longer + // floats favorited squads to the top — show them in plain alphabetical + // order instead of the boot-applied favorite-first sort. + const orderedSquads = + isV2 && squads + ? [...squads].sort((a, b) => + a.name + .toLocaleLowerCase() + .localeCompare(b.name.toLocaleLowerCase()), + ) + : squads; const squadItems = - squads?.map((squad) => { - const { name, image, handle } = squad; - return { - icon: () => - image ? ( - - ) : ( - - ), - title: name, - path: `${webappUrl}squads/${handle}`, - itemClassName: 'group/squad-row', - rightIcon: () => , - }; - }) ?? []; + orderedSquads?.map((squad) => createSquadMenuItem(squad, isV2)) ?? []; return [ { icon: (active: boolean) => ( @@ -77,7 +75,7 @@ export const NetworkSection = ({ }, ...squadItems, ].filter(Boolean) as SidebarMenuItem[]; - }, [squads, isModeratorInAnySquad, count]); + }, [squads, isV2, isModeratorInAnySquad, count]); return (
{ + const { squads } = useAuthContext(); + + const pinnedSquads = useMemo( + () => squads?.filter((squad) => !!squad.favoritedAt) ?? [], + [squads], + ); + + const menuItems: SidebarMenuItem[] = useMemo( + () => pinnedSquads.map((squad) => createSquadMenuItem(squad, true)), + [pinnedSquads], + ); + + // Always show the "Pinned" header (even when empty) so users discover the + // feature and understand what the rows below represent. + return ( +
+ ); +}; diff --git a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx index 00f42d5613a..eca460246ae 100644 --- a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx @@ -8,7 +8,6 @@ import { CreditCardIcon, EditIcon, EmbedIcon, - ExitIcon, EyeIcon, FeatherIcon, HashtagIcon, @@ -32,8 +31,6 @@ import { ListIcon } from '../common'; import { Section } from '../Section'; import type { SidebarSectionProps } from './common'; import { settingsUrl } from '../../../lib/constants'; -import { LogoutReason } from '../../../lib/user'; -import { logout } from '../../../contexts/AuthContext'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; import { useLogContext } from '../../../contexts/LogContext'; @@ -278,18 +275,6 @@ export const SettingsPanelSection = ({ }, ], }, - { - key: 'logout', - items: [ - { - title: 'Log out', - icon: (active: boolean) => ( - } /> - ), - action: () => logout(LogoutReason.ManualLogout), - }, - ], - }, ], [logEvent, openModal], ); diff --git a/packages/shared/src/components/sidebar/sections/common.tsx b/packages/shared/src/components/sidebar/sections/common.tsx index f15b316d0a8..47af2e051d6 100644 --- a/packages/shared/src/components/sidebar/sections/common.tsx +++ b/packages/shared/src/components/sidebar/sections/common.tsx @@ -5,4 +5,6 @@ export type SidebarSectionProps = { activePage: string; title?: string; onNavTabClick?: (page: string) => void; + // v2 single-panel sidebar: render compact (Linear-style) rows + headers. + compact?: boolean; }; diff --git a/packages/shared/src/components/sidebar/sections/squadMenuItem.tsx b/packages/shared/src/components/sidebar/sections/squadMenuItem.tsx new file mode 100644 index 00000000000..ce1ff406ede --- /dev/null +++ b/packages/shared/src/components/sidebar/sections/squadMenuItem.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { SidebarMenuItem } from '../common'; +import { DefaultSquadIcon } from '../../icons'; +import { SquadImage } from '../../squads/SquadImage'; +import { SquadFavoriteButton } from '../../squads/SquadFavoriteButton'; +import { webappUrl } from '../../../lib/constants'; +import type { Squad } from '../../../graphql/sources'; + +// Shared squad row for the sidebar (the full Squads list and the Home +// "Pinned" section render identical rows). `asPin` switches the favorite +// button to the v2 pin icon/label. +export const createSquadMenuItem = ( + squad: Squad, + asPin: boolean, +): SidebarMenuItem => { + const { name, image, handle } = squad; + return { + icon: () => + image ? ( + + ) : ( + + ), + title: name, + path: `${webappUrl}squads/${handle}`, + itemClassName: 'group/squad-row', + rightIcon: () => , + }; +}; diff --git a/packages/shared/src/components/sidebar/sidebarCategory.spec.ts b/packages/shared/src/components/sidebar/sidebarCategory.spec.ts index e2308f50dec..5faa61bed48 100644 --- a/packages/shared/src/components/sidebar/sidebarCategory.spec.ts +++ b/packages/shared/src/components/sidebar/sidebarCategory.spec.ts @@ -14,9 +14,11 @@ describe('getSidebarCategoryForPath', () => { ); }); - it('keeps the devcard customization page on the Profile category', () => { + it('maps the devcard customization page to the Settings category', () => { + // Profile moved to the bottom-rail dropdown, so its old sub-pages + // (devcard lives under /settings) now resolve to the Settings panel. expect(getSidebarCategoryForPath('/settings/customization/devcard')).toBe( - SidebarCategory.Profile, + SidebarCategory.Settings, ); }); @@ -33,10 +35,11 @@ describe('isSidebarSettingsPath', () => { expect(isSidebarSettingsPath('/settings/notifications')).toBe(true); }); - it('is false for the devcard page and non-settings pages', () => { - expect(isSidebarSettingsPath('/settings/customization/devcard')).toBe( - false, - ); + it('is true for settings pages including devcard customization', () => { + expect(isSidebarSettingsPath('/settings/customization/devcard')).toBe(true); + }); + + it('is false for non-settings pages', () => { expect(isSidebarSettingsPath('/notifications')).toBe(false); expect(isSidebarSettingsPath('/posts')).toBe(false); expect(isSidebarSettingsPath('/')).toBe(false); diff --git a/packages/shared/src/components/sidebar/sidebarCategory.ts b/packages/shared/src/components/sidebar/sidebarCategory.ts index ccb942cde74..fb59daecf66 100644 --- a/packages/shared/src/components/sidebar/sidebarCategory.ts +++ b/packages/shared/src/components/sidebar/sidebarCategory.ts @@ -4,31 +4,15 @@ export const SidebarCategory = { Main: 'main', Squads: 'squads', - Discover: 'discover', Notifications: 'notifications', Saved: 'saved', GameCenter: 'gameCenter', - Profile: 'profile', Settings: 'settings', } as const; export type SidebarCategoryId = (typeof SidebarCategory)[keyof typeof SidebarCategory]; -const discoverPathFragments = [ - '/posts', - '/tags', - '/sources', - '/users', - '/discussed', -]; -const profilePathFragments = [ - '/analytics', - '/jobs', - '/settings/customization/devcard', - '/wallet', -]; - export const getSidebarCategoryForPath = ( activePage: string, ): SidebarCategoryId => { @@ -55,15 +39,11 @@ export const getSidebarCategoryForPath = ( if (activePage.includes('/squads')) { return SidebarCategory.Squads; } - if (profilePathFragments.some((path) => activePage.includes(path))) { - return SidebarCategory.Profile; - } if (activePage.includes('/settings')) { return SidebarCategory.Settings; } - if (discoverPathFragments.some((path) => activePage.includes(path))) { - return SidebarCategory.Discover; - } + // Explore and its sub-pages (/posts, /tags, /sources, /users, /discussed) + // now live under Home, so they fall through to the Main category. return SidebarCategory.Main; }; diff --git a/packages/shared/src/components/squads/SquadFavoriteButton.tsx b/packages/shared/src/components/squads/SquadFavoriteButton.tsx index ae54876b1da..0e2b6235061 100644 --- a/packages/shared/src/components/squads/SquadFavoriteButton.tsx +++ b/packages/shared/src/components/squads/SquadFavoriteButton.tsx @@ -2,7 +2,7 @@ import type { MouseEvent, ReactElement } from 'react'; import React, { useCallback } from 'react'; import classNames from 'classnames'; import type { Squad } from '../../graphql/sources'; -import { StarIcon } from '../icons'; +import { PinIcon, StarIcon } from '../icons'; import type { IconSize } from '../Icon'; import { useSquadFavorite } from '../../hooks/squads/useSquadFavorite'; @@ -10,15 +10,26 @@ interface SquadFavoriteButtonProps { squad: Squad; className?: string; iconSize?: IconSize; + // v2 reframes "favorite" as "pin" (placed in the Home → Pinned section). + // Passed by the v2 callers so this shared button stays context-free. + asPin?: boolean; } export const SquadFavoriteButton = ({ squad, className, iconSize, + asPin = false, }: SquadFavoriteButtonProps): ReactElement => { const { toggleFavorite, isPending } = useSquadFavorite(); const isFavorited = !!squad.favoritedAt; + const Icon = asPin ? PinIcon : StarIcon; + const label = (() => { + if (asPin) { + return isFavorited ? 'Unpin squad' : 'Pin squad'; + } + return isFavorited ? 'Unfavorite squad' : 'Favorite squad'; + })(); const onClick = useCallback( (event: MouseEvent) => { @@ -32,18 +43,21 @@ export const SquadFavoriteButton = ({ return ( ); }; diff --git a/packages/shared/src/components/tooltips/InteractivePopup.tsx b/packages/shared/src/components/tooltips/InteractivePopup.tsx index 72c85ee70ce..903a1809ce7 100644 --- a/packages/shared/src/components/tooltips/InteractivePopup.tsx +++ b/packages/shared/src/components/tooltips/InteractivePopup.tsx @@ -25,6 +25,7 @@ export enum InteractivePopupPosition { ProfileMenu = 'profileMenu', Screen = 'screen', SidebarSupportMenu = 'sidebarSupportMenu', + SidebarProfileMenu = 'sidebarProfileMenu', } type CloseButtonProps = { @@ -63,7 +64,8 @@ const positionClass: Record = { leftEnd: classNames(leftClass, endClass), profileMenu: classNames(profileMenuRightClass, 'top-14'), screen: 'inset-0 w-screen h-screen', - sidebarSupportMenu: 'left-16 bottom-3 ml-2', + sidebarSupportMenu: 'left-3 bottom-16', + sidebarProfileMenu: 'left-3 top-16', }; const leftPositions = [ @@ -145,6 +147,7 @@ function InteractivePopup({ > {finalPosition !== InteractivePopupPosition.ProfileMenu && finalPosition !== InteractivePopupPosition.SidebarSupportMenu && + finalPosition !== InteractivePopupPosition.SidebarProfileMenu && onClose && (