From 0e422f731ddf68938852818a2f0b5202b9d5cd86 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 11 May 2026 15:18:41 +0300 Subject: [PATCH 01/34] feat: introduce dual desktop sidebar Co-authored-by: Cursor --- packages/shared/src/components/MainLayout.tsx | 27 +- .../components/layout/MainLayoutHeader.tsx | 21 +- .../src/components/sidebar/Sidebar.spec.tsx | 32 +- .../src/components/sidebar/SidebarDesktop.tsx | 287 ++++++++++++++---- packages/shared/src/contexts/FeedContext.tsx | 4 +- .../shared/src/contexts/SettingsContext.tsx | 2 + packages/shared/src/graphql/settings.ts | 10 + 7 files changed, 292 insertions(+), 91 deletions(-) diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 8a95549255..41f4e41384 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -24,7 +24,6 @@ import { ActiveFeedNameContextProvider, useActiveFeedNameContext, } from '../contexts'; -import { useFeedLayout, useViewSize, ViewSize } from '../hooks'; import { BootPopups } from './modals/BootPopups'; import { SmartComposerHotkey } from './post/SmartComposerHotkey'; import { SmartComposerDevToggle } from './post/SmartComposerDevToggle'; @@ -76,7 +75,6 @@ function MainLayoutComponent({ isNavItemsButton, customBanner, additionalButtons, - screenCentered = true, showSidebar = true, className, onLogoClick, @@ -98,8 +96,6 @@ function MainLayoutComponent({ const currentFeedName = feedName ?? page ?? SharedFeedPage.Popular; const { isCustomFeed } = useFeedName({ feedName: currentFeedName }); const { plusEntryAnnouncementBar } = usePlusEntry(); - const isLaptopXL = useViewSize(ViewSize.LaptopXL); - const { screenCenteredOnMobileLayout } = useFeedLayout(); const { isNotificationsReady, unreadCount } = useNotificationContext(); useNotificationParams(); @@ -177,9 +173,6 @@ function MainLayoutComponent({ return null; } - const isScreenCentered = - isLaptopXL && screenCenteredOnMobileLayout ? true : screenCentered; - return (
{canGoBack && } @@ -203,19 +196,16 @@ function MainLayoutComponent({
@@ -227,7 +217,16 @@ function MainLayoutComponent({ activePage={activePage ?? router.asPath ?? router.pathname} /> )} - {children} +
+ {children} +
{!hideFeedbackWidget && }
diff --git a/packages/shared/src/components/layout/MainLayoutHeader.tsx b/packages/shared/src/components/layout/MainLayoutHeader.tsx index ec13ee1791..ef9f1fdcc1 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -36,7 +36,7 @@ function MainLayoutHeader({ additionalButtons, onLogoClick, }: MainLayoutHeaderProps): ReactElement { - const { loadedSettings } = useSettingsContext(); + const { loadedSettings, sidebarExpanded } = useSettingsContext(); // Header is `fixed` so it escapes the parent's `padding-right`. Read // the customize sidebar width directly here and shrink the header to // match — keeps it from sliding under the panel and lets it animate @@ -62,6 +62,12 @@ function MainLayoutHeader({ shouldUseLoadedSettings && isMobile && isSearchPage; const shouldRenderFeedNav = shouldUseLoadedSettings && isMobile && !isSearchPage; + const sidebarWidth = sidebarRendered + ? sidebarExpanded + ? '19rem' + : '4rem' + : undefined; + const customizerWidth = panelWidth ? `${panelWidth}px` : '0px'; useEffect(() => { setHasHydrated(true); @@ -110,11 +116,16 @@ function MainLayoutHeader({ )} style={{ ...(featureTheme ? featureTheme.navbar : undefined), + left: sidebarWidth, right: panelWidth || undefined, - width: panelWidth ? `calc(100% - ${panelWidth}px)` : undefined, - transition: panelWidth - ? 'right 200ms ease-in-out, width 200ms ease-in-out' - : undefined, + width: + sidebarWidth || panelWidth + ? `calc(100% - ${sidebarWidth ?? '0rem'} - ${customizerWidth})` + : undefined, + transition: + sidebarWidth || panelWidth + ? 'left 300ms ease-in-out, right 200ms ease-in-out, width 300ms ease-in-out' + : undefined, }} > {isMobileSearchPage ? ( diff --git a/packages/shared/src/components/sidebar/Sidebar.spec.tsx b/packages/shared/src/components/sidebar/Sidebar.spec.tsx index a370891f3d..9f8e8ad0a2 100644 --- a/packages/shared/src/components/sidebar/Sidebar.spec.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.spec.tsx @@ -95,10 +95,16 @@ const renderComponent = ( it('should render the sidebar as open by default', async () => { renderComponent(); - const section = await screen.findByText('Discover'); + const categoryRail = await screen.findByRole('tablist', { + name: 'Sidebar categories', + }); + expect(categoryRail).toBeInTheDocument(); + const section = await screen.findByText('Main'); expect(section).toBeInTheDocument(); - const sectionTwo = await screen.findByText('Squads'); - expect(sectionTwo).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Main' })).toHaveAttribute( + 'aria-selected', + 'true', + ); }); it('should toggle the sidebar on button click', async () => { @@ -113,8 +119,8 @@ it('should show the sidebar as closed if user has this set', async () => { const trigger = await screen.findByLabelText('Open sidebar'); expect(trigger).toBeInTheDocument(); - const section = await screen.findByText('Discover'); - expect(section).toHaveClass('opacity-0'); + const panel = await screen.findByRole('tabpanel', { hidden: true }); + expect(panel).toHaveClass('opacity-0'); }); it('should show the For You items if the user has filters', async () => { @@ -149,6 +155,9 @@ it('should require login before opening following for anonymous users', async () const sidebarItems = [ ['Explore', '/posts'], +]; + +const discoverItems = [ ['Discussions', '/discussed'], ['Tags', '/tags'], ['Sources', '/sources'], @@ -167,4 +176,17 @@ describe('sidebar items', () => { expect(el.closest('a')).toHaveAttribute('href', href); }, ); + + it.each(discoverItems.map((item) => [item[0], item[1]]))( + 'it should expect %s to exist in discover', + async (name, href) => { + renderComponent(); + waitForNock(); + fireEvent.click(await screen.findByRole('tab', { name: 'Discover' })); + const el = await screen.findByText(name); + expect(el).toBeInTheDocument(); + // eslint-disable-next-line testing-library/no-node-access + expect(el.closest('a')).toHaveAttribute('href', href); + }, + ); }); diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index f11801a9c9..c52cd61864 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; import { Nav, SidebarAside, SidebarScrollWrapper } from './common'; import { useSettingsContext } from '../../contexts/SettingsContext'; @@ -14,6 +14,90 @@ import { ButtonSize } from '../buttons/Button'; import { BookmarkSection } from './sections/BookmarkSection'; import { NetworkSection } from './sections/NetworkSection'; import { HelpWidget } from '../help/HelpWidget'; +import { + BookmarkIcon, + HashtagIcon, + HomeIcon, + HotIcon, + SourceIcon, +} from '../icons'; +import { IconSize } from '../Icon'; +import { + SidebarSelectedCategory, + SidebarSettingsFlags, +} from '../../graphql/settings'; +import { Tooltip } from '../tooltip/Tooltip'; + +type SidebarCategoryConfig = { + id: SidebarSelectedCategory; + label: string; + icon: (active: boolean) => ReactElement; +}; + +const sidebarCategories: SidebarCategoryConfig[] = [ + { + id: SidebarSelectedCategory.Main, + label: 'Main', + icon: (active) => ( + + ), + }, + { + id: SidebarSelectedCategory.Feeds, + label: 'Feeds', + icon: (active) => ( + + ), + }, + { + id: SidebarSelectedCategory.Squads, + label: 'Squads', + icon: (active) => ( + + ), + }, + { + id: SidebarSelectedCategory.Saved, + label: 'Saved', + icon: (active) => ( + + ), + }, + { + id: SidebarSelectedCategory.Discover, + label: 'Discover', + icon: (active) => ( + + ), + }, +]; + +const getSidebarCategoryForPath = ( + activePage: string, +): SidebarSelectedCategory => { + if (activePage.includes('/bookmarks') || activePage.includes('/briefing')) { + return SidebarSelectedCategory.Saved; + } + + if (activePage.includes('/squads')) { + return SidebarSelectedCategory.Squads; + } + + if (activePage.includes('/feeds/')) { + return SidebarSelectedCategory.Feeds; + } + + if ( + activePage.includes('/tags') || + activePage.includes('/sources') || + activePage.includes('/users') || + activePage.includes('/discussed') + ) { + return SidebarSelectedCategory.Discover; + } + + return SidebarSelectedCategory.Main; +}; type SidebarDesktopProps = { activePage?: string; @@ -31,89 +115,162 @@ export const SidebarDesktop = ({ onNavTabClick, }: SidebarDesktopProps): ReactElement => { const router = useRouter(); - const { sidebarExpanded } = useSettingsContext(); + const { flags, sidebarExpanded, updateFlag } = useSettingsContext(); const { isAvailable: isBannerAvailable } = useBanner(); const activePage = activePageProp || router.asPath || router.pathname; + const [selectedCategory, setSelectedCategory] = useState( + flags?.sidebarSelectedCategory ?? getSidebarCategoryForPath(activePage), + ); + + useEffect(() => { + const activeCategory = getSidebarCategoryForPath(activePage); + + setSelectedCategory( + activeCategory === SidebarSelectedCategory.Main + ? flags?.sidebarSelectedCategory ?? activeCategory + : activeCategory, + ); + }, [activePage, flags?.sidebarSelectedCategory]); const defaultRenderSectionProps = useMemo( () => ({ - sidebarExpanded, - shouldShowLabel: sidebarExpanded, + sidebarExpanded: true, + shouldShowLabel: true, activePage, }), - [sidebarExpanded, activePage], + [activePage], ); + const onSelectCategory = useCallback( + (category: SidebarSelectedCategory) => { + setSelectedCategory(category); + updateFlag(SidebarSettingsFlags.SelectedCategory, category); + }, + [updateFlag], + ); + + const renderSelectedSection = (): ReactElement => { + if (selectedCategory === SidebarSelectedCategory.Feeds) { + return ( + + ); + } + + if (selectedCategory === SidebarSelectedCategory.Squads) { + return ( + + ); + } + + if (selectedCategory === SidebarSelectedCategory.Saved) { + return ( + + ); + } + + if (selectedCategory === SidebarSelectedCategory.Discover) { + return ( + + ); + } + + return ( + + ); + }; + return ( - - - - - {/* Help guide — pinned to sidebar bottom (renders only when a marketingCTA is targeted) */} - + + + ); }; diff --git a/packages/shared/src/contexts/FeedContext.tsx b/packages/shared/src/contexts/FeedContext.tsx index 38e785b256..e91d28520e 100644 --- a/packages/shared/src/contexts/FeedContext.tsx +++ b/packages/shared/src/contexts/FeedContext.tsx @@ -105,8 +105,8 @@ const replaceDigitsWithIncrement = (str: string, increment: number): string => { return str.replace(match[0], `${parseInt(match[0], 10) + increment}`); }; -const sidebarRenderedWidth = 44; -const sidebarOpenWidth = 240; +const sidebarRenderedWidth = 64; +const sidebarOpenWidth = 304; const FeedContext = React.createContext( baseFeedSettings.default, diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index 1590cb9b69..694fc0b56c 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -15,6 +15,7 @@ import type { } from '../graphql/settings'; import { CampaignCtaPlacement, + SidebarSelectedCategory, UPDATE_USER_SETTINGS_MUTATION, } from '../graphql/settings'; import { WriteFormTab } from '../components/fields/form/common'; @@ -146,6 +147,7 @@ const defaultSettings: RemoteSettings = { sidebarOtherExpanded: true, sidebarResourcesExpanded: true, sidebarBookmarksExpanded: true, + sidebarSelectedCategory: SidebarSelectedCategory.Main, clickbaitShieldEnabled: true, defaultWriteTab: WriteFormTab.NewPost, }, diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 9395806461..e8ea9c5b3c 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -43,6 +43,7 @@ export type SettingsFlags = { sidebarOtherExpanded: boolean; sidebarResourcesExpanded: boolean; sidebarBookmarksExpanded: boolean; + sidebarSelectedCategory?: SidebarSelectedCategory; clickbaitShieldEnabled: boolean; timezoneMismatchIgnore?: string; prompt?: Record; @@ -64,9 +65,18 @@ export enum SidebarSettingsFlags { OtherExpanded = 'sidebarOtherExpanded', ResourcesExpanded = 'sidebarResourcesExpanded', BookmarksExpanded = 'sidebarBookmarksExpanded', + SelectedCategory = 'sidebarSelectedCategory', ClickbaitShieldEnabled = 'clickbaitShieldEnabled', } +export enum SidebarSelectedCategory { + Main = 'main', + Feeds = 'feeds', + Squads = 'squads', + Saved = 'saved', + Discover = 'discover', +} + export type RemoteSettings = { openNewTab: boolean; theme: RemoteTheme; From 741e26bccd2f184d5bf0fe3c66f45a6c45d11efe Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 11 May 2026 16:01:16 +0300 Subject: [PATCH 02/34] refactor(sidebar): polish dual sidebar, drop nested boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dual sidebar shipped with three stacked cards (rail + panel + content) that felt visually noisy. Strip the chrome around the rail and panel so they sit flat against the page background and the content becomes the only elevated surface. - Hide the global header on laptop+ for logged-in users and relocate its contents into the rail: logo at top, search/notifications/profile pinned to the bottom alongside the expand toggle. Opportunity and quest entries move to the bottom of the contextual panel. - Drop the explicit borders and shadows from the rail and panel; the panel keeps a quiet divider above its footer actions, nothing more. - Replace the dotted left-edge accent with a soft surface pill for the active category — quieter and matches the rest of the design system. - Floating content card now uses a single elevation (rounded-24, surface-float, shadow-2, no border) with a small left margin so the panel and the card don't visually collide. Co-authored-by: Cursor --- packages/shared/src/components/MainLayout.tsx | 17 ++- .../layout/MainLayoutHeader.spec.tsx | 7 + .../components/layout/MainLayoutHeader.tsx | 38 +++-- .../src/components/sidebar/Sidebar.spec.tsx | 38 ++++- .../shared/src/components/sidebar/Sidebar.tsx | 1 + .../src/components/sidebar/SidebarDesktop.tsx | 133 +++++++++++++++--- 6 files changed, 180 insertions(+), 54 deletions(-) diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 41f4e41384..ffed123309 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -84,12 +84,17 @@ function MainLayoutComponent({ }: MainLayoutProps): ReactElement | null { const router = useRouter(); const { logEvent } = useLogContext(); - const { user, isAuthReady, showLogin } = useAuthContext(); + const { user, isAuthReady, isLoggedIn, showLogin } = useAuthContext(); const { growthbook } = useGrowthBookContext(); const { sidebarRendered } = useSidebarRendered(); const { isAvailable: isBannerAvailable } = useBanner(); const { sidebarExpanded, autoDismissNotifications } = useContext(SettingsContext); + // The dual-sidebar layout takes ownership of the global header chrome + // (logo + search + user actions) for authenticated users on laptop+. + // When that's the case we hide the global header and switch the main + // content over to the floating card treatment. + const sidebarOwnsHeader = isLoggedIn && showSidebar && sidebarRendered; const [hasLoggedImpression, setHasLoggedImpression] = useState(false); const { feedName } = useActiveFeedNameContext(); const page = router?.route?.substring(1).trim() as SharedFeedPage; @@ -202,11 +207,12 @@ function MainLayoutComponent({ />
{isAuthReady && showSidebar && ( @@ -220,9 +226,8 @@ function MainLayoutComponent({
{children} diff --git a/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx b/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx index c6b0366b18..540d8159f1 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx @@ -14,6 +14,7 @@ import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; import { useScrollTopClassName } from '../../hooks/useScrollTopClassName'; import { useFeedName } from '../../hooks/feed/useFeedName'; import useActiveNav from '../../hooks/useActiveNav'; +import { useAuthContext } from '../../contexts/AuthContext'; jest.mock('next/dynamic', () => () => { return function MockDynamicComponent() { @@ -54,6 +55,10 @@ jest.mock('../../hooks/feed/useFeedName', () => ({ jest.mock('../../hooks/useActiveNav', () => jest.fn()); +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + jest.mock('../header/MobileExploreHeader', () => ({ MobileExploreHeader: ({ path }: { path: string }) => (
{path}
@@ -68,6 +73,7 @@ const mockUseFeatureTheme = useFeatureTheme as jest.Mock; const mockUseScrollTopClassName = useScrollTopClassName as jest.Mock; const mockUseFeedName = useFeedName as jest.Mock; const mockUseActiveNav = useActiveNav as jest.Mock; +const mockUseAuthContext = useAuthContext as jest.Mock; describe('MainLayoutHeader', () => { beforeEach(() => { @@ -84,6 +90,7 @@ describe('MainLayoutHeader', () => { isSearch: false, }); mockUseActiveNav.mockReturnValue({ profile: false }); + mockUseAuthContext.mockReturnValue({ isLoggedIn: false }); }); afterEach(() => { diff --git a/packages/shared/src/components/layout/MainLayoutHeader.tsx b/packages/shared/src/components/layout/MainLayoutHeader.tsx index ef9f1fdcc1..e6253b66d6 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -17,6 +17,7 @@ import FeedNav from '../feeds/FeedNav'; import { MobileExploreHeader } from '../header/MobileExploreHeader'; import useActiveNav from '../../hooks/useActiveNav'; import { SpotlightTrigger } from '../spotlight/SpotlightTrigger'; +import { useAuthContext } from '../../contexts/AuthContext'; export interface MainLayoutHeaderProps { hasBanner?: boolean; @@ -35,12 +36,8 @@ function MainLayoutHeader({ sidebarRendered, additionalButtons, onLogoClick, -}: MainLayoutHeaderProps): ReactElement { - const { loadedSettings, sidebarExpanded } = useSettingsContext(); - // Header is `fixed` so it escapes the parent's `padding-right`. Read - // the customize sidebar width directly here and shrink the header to - // match — keeps it from sliding under the panel and lets it animate - // alongside the feed. +}: MainLayoutHeaderProps): ReactElement | null { + const { loadedSettings } = useSettingsContext(); const { panelWidth } = useCustomizeNewTab(); const [hasHydrated, setHasHydrated] = useState(false); const { streak, isStreaksEnabled } = useReadingStreak(); @@ -62,11 +59,13 @@ function MainLayoutHeader({ shouldUseLoadedSettings && isMobile && isSearchPage; const shouldRenderFeedNav = shouldUseLoadedSettings && isMobile && !isSearchPage; - const sidebarWidth = sidebarRendered - ? sidebarExpanded - ? '19rem' - : '4rem' - : undefined; + const { isLoggedIn } = useAuthContext(); + // The dual-sidebar layout owns the logo, search, and action buttons on + // laptop+ for authenticated users. Skip the global header entirely so + // chrome isn't duplicated and the content card can sit flush to the top. + // Logged-out users still see the header because the sidebar doesn't + // surface a login control. + const shouldHideForSidebar = isLaptop && !!sidebarRendered && isLoggedIn; const customizerWidth = panelWidth ? `${panelWidth}px` : '0px'; useEffect(() => { @@ -93,6 +92,10 @@ function MainLayoutHeader({ ); }, [shouldUseLoadedSettings, isSearchPage, hasBanner]); + if (shouldHideForSidebar) { + return null; + } + if (shouldRenderFeedNav) { return ( <> @@ -116,16 +119,11 @@ function MainLayoutHeader({ )} style={{ ...(featureTheme ? featureTheme.navbar : undefined), - left: sidebarWidth, right: panelWidth || undefined, - width: - sidebarWidth || panelWidth - ? `calc(100% - ${sidebarWidth ?? '0rem'} - ${customizerWidth})` - : undefined, - transition: - sidebarWidth || panelWidth - ? 'left 300ms ease-in-out, right 200ms ease-in-out, width 300ms ease-in-out' - : undefined, + width: panelWidth ? `calc(100% - ${customizerWidth})` : undefined, + transition: panelWidth + ? 'right 200ms ease-in-out, width 300ms ease-in-out' + : undefined, }} > {isMobileSearchPage ? ( diff --git a/packages/shared/src/components/sidebar/Sidebar.spec.tsx b/packages/shared/src/components/sidebar/Sidebar.spec.tsx index 9f8e8ad0a2..ea4facca9c 100644 --- a/packages/shared/src/components/sidebar/Sidebar.spec.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.spec.tsx @@ -16,8 +16,34 @@ import { waitForNock } from '../../../__tests__/helpers/utilities'; import ProgressiveEnhancementContext from '../../contexts/ProgressiveEnhancementContext'; import type { Alerts } from '../../graphql/alerts'; import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; +import { SpotlightProvider } from '../spotlight/SpotlightContext'; import { SidebarDesktop } from './SidebarDesktop'; +jest.mock('../notifications/NotificationsBell', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../profile/ProfileButton', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../opportunity/OpportunityEntryButton', () => ({ + __esModule: true, + OpportunityEntryButton: () => null, +})); +jest.mock('../header/QuestHeaderButton', () => ({ + __esModule: true, + QuestHeaderButton: () => null, +})); +jest.mock('../help/HelpWidget', () => ({ + __esModule: true, + HelpWidget: () => null, +})); +jest.mock('../layout/HeaderLogo', () => ({ + __esModule: true, + default: () => null, +})); + let client: QueryClient; const updateAlerts = jest.fn(); const toggleSidebarExpanded = jest.fn(); @@ -80,11 +106,13 @@ const renderComponent = ( }} > - + + + diff --git a/packages/shared/src/components/sidebar/Sidebar.tsx b/packages/shared/src/components/sidebar/Sidebar.tsx index 347ed94903..d049d3333a 100644 --- a/packages/shared/src/components/sidebar/Sidebar.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.tsx @@ -52,6 +52,7 @@ export const Sidebar = ({ featureTheme={featureTheme} isNavButtons={isNavButtons} onNavTabClick={onNavTabClick} + onLogoClick={onLogoClick} /> ); } diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index c52cd61864..3ea98fffe5 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; import { Nav, SidebarAside, SidebarScrollWrapper } from './common'; import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useLogContext } from '../../contexts/LogContext'; import { useBanner } from '../../hooks/useBanner'; import { MainSection } from './sections/MainSection'; import { CustomFeedSection } from './sections/CustomFeedSection'; import { DiscoverSection } from './sections/DiscoverSection'; -import { SidebarMenuIcon } from './SidebarMenuIcon'; import { CreatePostButton } from '../post/write'; -import { ButtonSize } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { BookmarkSection } from './sections/BookmarkSection'; import { NetworkSection } from './sections/NetworkSection'; import { HelpWidget } from '../help/HelpWidget'; @@ -20,6 +20,9 @@ import { HomeIcon, HotIcon, SourceIcon, + SearchIcon, + SidebarArrowLeft, + SidebarArrowRight, } from '../icons'; import { IconSize } from '../Icon'; import { @@ -27,6 +30,14 @@ import { SidebarSettingsFlags, } from '../../graphql/settings'; import { Tooltip } from '../tooltip/Tooltip'; +import HeaderLogo from '../layout/HeaderLogo'; +import { LogoPosition } from '../Logo'; +import { useSpotlight } from '../spotlight/useSpotlight'; +import { useAuthContext } from '../../contexts/AuthContext'; +import NotificationsBell from '../notifications/NotificationsBell'; +import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton'; +import { QuestHeaderButton } from '../header/QuestHeaderButton'; +import ProfileButton from '../profile/ProfileButton'; type SidebarCategoryConfig = { id: SidebarSelectedCategory; @@ -99,6 +110,9 @@ const getSidebarCategoryForPath = ( return SidebarSelectedCategory.Main; }; +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'; + type SidebarDesktopProps = { activePage?: string; featureTheme?: { @@ -107,16 +121,22 @@ type SidebarDesktopProps = { }; isNavButtons?: boolean; onNavTabClick?: (tab: string) => void; + onLogoClick?: (e: React.MouseEvent) => unknown; }; export const SidebarDesktop = ({ activePage: activePageProp, featureTheme, isNavButtons, onNavTabClick, + onLogoClick, }: SidebarDesktopProps): ReactElement => { const router = useRouter(); - const { flags, sidebarExpanded, updateFlag } = useSettingsContext(); + const { flags, sidebarExpanded, toggleSidebarExpanded, updateFlag } = + useSettingsContext(); + const { logEvent } = useLogContext(); const { isAvailable: isBannerAvailable } = useBanner(); + const { open: openSpotlight } = useSpotlight(); + const { isLoggedIn } = useAuthContext(); const activePage = activePageProp || router.asPath || router.pathname; const [selectedCategory, setSelectedCategory] = useState( flags?.sidebarSelectedCategory ?? getSidebarCategoryForPath(activePage), @@ -149,6 +169,13 @@ export const SidebarDesktop = ({ [updateFlag], ); + const onToggleExpanded = useCallback(() => { + logEvent({ + event_name: `${sidebarExpanded ? 'open' : 'close'} sidebar`, + }); + toggleSidebarExpanded(); + }, [logEvent, sidebarExpanded, toggleSidebarExpanded]); + const renderSelectedSection = (): ReactElement => { if (selectedCategory === SidebarSelectedCategory.Feeds) { return ( @@ -201,23 +228,40 @@ export const SidebarDesktop = ({ ); }; + const selectedLabel = sidebarCategories.find( + (category) => category.id === selectedCategory, + )?.label; + return ( From e9b0d725f88f511f3ce99cda008a290fb91ba2f5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 11 May 2026 16:15:04 +0300 Subject: [PATCH 03/34] refactor(sidebar): swap surface tones, surface profile identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two design tweaks based on review feedback: 1. Swap the elevation. The sidebar now sits on `bg-surface-float` while the feed/content area uses the page background. The sidebar reads as a quiet tray and the feed flows on the page — the inverse of the previous treatment that had the feed floating over the sidebar. 2. Restore the user's profile picture and stats. The rail uses a new `compact` mode on `ProfileButton` that renders the avatar (clickable, opens the menu) instead of a generic settings icon. The full pill with streak / cores / reputation / avatar now lives at the bottom of the contextual panel, next to the jobs and quest entries. Also bump the active-category background to `bg-surface-active` so it remains distinct now that the rail itself is `surface-float`, and drop the now-redundant floating card styling on the content container. Co-authored-by: Cursor --- packages/shared/src/components/MainLayout.tsx | 10 +------ .../src/components/profile/ProfileButton.tsx | 28 +++++++++++++++++++ .../src/components/sidebar/SidebarDesktop.tsx | 19 +++++++------ 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index ffed123309..d1899a8e6e 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -223,15 +223,7 @@ function MainLayoutComponent({ activePage={activePage ?? router.asPath ?? router.pathname} /> )} -
- {children} -
+
{children}
{!hideFeedbackWidget && } diff --git a/packages/shared/src/components/profile/ProfileButton.tsx b/packages/shared/src/components/profile/ProfileButton.tsx index 08c4675e29..74c407951b 100644 --- a/packages/shared/src/components/profile/ProfileButton.tsx +++ b/packages/shared/src/components/profile/ProfileButton.tsx @@ -29,11 +29,18 @@ const ProfileMenu = dynamic( interface ProfileButtonProps { className?: string; settingsIconOnly?: boolean; + /** + * Renders the trigger as just the user's avatar (clickable, opens the + * profile menu). Used in places like the sidebar rail where the full + * streak/cores/reputation pill doesn't fit. + */ + compact?: boolean; } export default function ProfileButton({ className, settingsIconOnly, + compact, }: ProfileButtonProps): ReactElement { const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); const { user, isAuthReady } = useAuthContext(); @@ -197,6 +204,27 @@ export default function ProfileButton({ return <>; } + if (compact) { + return ( + <> + + + + {isOpen && onUpdate(false)} />} + + ); + } + return ( <> {settingsIconOnly ? ( diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index 3ea98fffe5..5857071ebb 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -236,7 +236,7 @@ export const SidebarDesktop = ({ onSelectCategory(category.id)} className={classNames( railButtonClass, - isSelected && 'bg-surface-float text-text-primary', + isSelected && 'bg-surface-active text-text-primary', )} > {category.icon(isSelected)} @@ -303,9 +303,7 @@ export const SidebarDesktop = ({
-
- -
+ )} {isLoggedIn && ( -
- - +
+
+ + +
+
+ +
)} From 3fc335e64b1aacfcdd709ef938e8fd83e03c3d56 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 11 May 2026 16:21:20 +0300 Subject: [PATCH 04/34] fix(sidebar): restore layout behavior after color swap Restore the floating content shell so the dual sidebar keeps the same behavior as the prior working version, while only swapping the surface colors: the sidebar uses the elevated surface and the feed card uses the page background. Replace the reused header profile pill in the panel footer with a sidebar-specific profile summary. It keeps the avatar/profile menu in a compact trigger and presents reputation, streak, and cores as small sidebar-friendly stats so the footer no longer distorts the panel. Co-authored-by: Cursor --- packages/shared/src/components/MainLayout.tsx | 10 ++- .../src/components/sidebar/SidebarDesktop.tsx | 83 +++++++++++++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index d1899a8e6e..e61ca501e8 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -223,7 +223,15 @@ function MainLayoutComponent({ activePage={activePage ?? router.asPath ?? router.pathname} /> )} -
{children}
+
+ {children} +
{!hideFeedbackWidget && }
diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index 5857071ebb..d793b864a0 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -16,6 +16,7 @@ import { NetworkSection } from './sections/NetworkSection'; import { HelpWidget } from '../help/HelpWidget'; import { BookmarkIcon, + CoreIcon, HashtagIcon, HomeIcon, HotIcon, @@ -38,6 +39,13 @@ import NotificationsBell from '../notifications/NotificationsBell'; import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton'; import { QuestHeaderButton } from '../header/QuestHeaderButton'; import ProfileButton from '../profile/ProfileButton'; +import { ReputationUserBadge } from '../ReputationUserBadge'; +import { ReadingStreakButton } from '../streak/ReadingStreakButton'; +import { useReadingStreak } from '../../hooks/streaks'; +import { useHasAccessToCores } from '../../hooks/useCoresFeature'; +import Link from '../utilities/Link'; +import { walletUrl } from '../../lib/constants'; +import { largeNumberFormat } from '../../lib'; type SidebarCategoryConfig = { id: SidebarSelectedCategory; @@ -113,6 +121,73 @@ const getSidebarCategoryForPath = ( 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 SidebarProfileSummary = (): ReactElement | null => { + const { user } = useAuthContext(); + const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); + const hasCoresAccess = useHasAccessToCores(); + + if (!user) { + return null; + } + + const shouldShowStats = (isStreaksEnabled && streak) || hasCoresAccess; + + return ( +
+
+ +
+

+ {user.name || user.username} +

+
+ +
+
+
+ + {shouldShowStats && ( +
+ {isStreaksEnabled && streak && ( + + )} + {hasCoresAccess && ( + + Wallet +
+ {largeNumberFormat(user.balance.amount)} Cores + + } + > + +
+ )} +
+ )} +
+ ); +}; + type SidebarDesktopProps = { activePage?: string; featureTheme?: { @@ -348,14 +423,12 @@ export const SidebarDesktop = ({ {isLoggedIn && ( -
-
+
+ +
-
- -
)} From f864b915e3987dee2063ae6bf09b9db8830008e7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 11 May 2026 16:27:11 +0300 Subject: [PATCH 05/34] fix(sidebar): restore exact layout and swap only container colors Revert the sidebar/profile structural changes introduced after the approved layout iteration and keep behavior/padding exactly as before. Apply a strict color-only swap between the two main containers: - sidebar shell uses `bg-surface-float` - feed shell uses `bg-background-default` No layout, spacing, or interaction changes beyond that token swap. Co-authored-by: Cursor --- .../src/components/profile/ProfileButton.tsx | 28 ------ .../src/components/sidebar/SidebarDesktop.tsx | 90 ++----------------- 2 files changed, 7 insertions(+), 111 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileButton.tsx b/packages/shared/src/components/profile/ProfileButton.tsx index 74c407951b..08c4675e29 100644 --- a/packages/shared/src/components/profile/ProfileButton.tsx +++ b/packages/shared/src/components/profile/ProfileButton.tsx @@ -29,18 +29,11 @@ const ProfileMenu = dynamic( interface ProfileButtonProps { className?: string; settingsIconOnly?: boolean; - /** - * Renders the trigger as just the user's avatar (clickable, opens the - * profile menu). Used in places like the sidebar rail where the full - * streak/cores/reputation pill doesn't fit. - */ - compact?: boolean; } export default function ProfileButton({ className, settingsIconOnly, - compact, }: ProfileButtonProps): ReactElement { const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); const { user, isAuthReady } = useAuthContext(); @@ -204,27 +197,6 @@ export default function ProfileButton({ return <>; } - if (compact) { - return ( - <> - - - - {isOpen && onUpdate(false)} />} - - ); - } - return ( <> {settingsIconOnly ? ( diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index d793b864a0..2ea0899367 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -16,7 +16,6 @@ import { NetworkSection } from './sections/NetworkSection'; import { HelpWidget } from '../help/HelpWidget'; import { BookmarkIcon, - CoreIcon, HashtagIcon, HomeIcon, HotIcon, @@ -39,13 +38,6 @@ import NotificationsBell from '../notifications/NotificationsBell'; import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton'; import { QuestHeaderButton } from '../header/QuestHeaderButton'; import ProfileButton from '../profile/ProfileButton'; -import { ReputationUserBadge } from '../ReputationUserBadge'; -import { ReadingStreakButton } from '../streak/ReadingStreakButton'; -import { useReadingStreak } from '../../hooks/streaks'; -import { useHasAccessToCores } from '../../hooks/useCoresFeature'; -import Link from '../utilities/Link'; -import { walletUrl } from '../../lib/constants'; -import { largeNumberFormat } from '../../lib'; type SidebarCategoryConfig = { id: SidebarSelectedCategory; @@ -121,73 +113,6 @@ const getSidebarCategoryForPath = ( 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 SidebarProfileSummary = (): ReactElement | null => { - const { user } = useAuthContext(); - const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); - const hasCoresAccess = useHasAccessToCores(); - - if (!user) { - return null; - } - - const shouldShowStats = (isStreaksEnabled && streak) || hasCoresAccess; - - return ( -
-
- -
-

- {user.name || user.username} -

-
- -
-
-
- - {shouldShowStats && ( -
- {isStreaksEnabled && streak && ( - - )} - {hasCoresAccess && ( - - Wallet -
- {largeNumberFormat(user.balance.amount)} Cores - - } - > - -
- )} -
- )} -
- ); -}; - type SidebarDesktopProps = { activePage?: string; featureTheme?: { @@ -352,7 +277,7 @@ export const SidebarDesktop = ({ onClick={() => onSelectCategory(category.id)} className={classNames( railButtonClass, - isSelected && 'bg-surface-active text-text-primary', + isSelected && 'bg-surface-float text-text-primary', )} > {category.icon(isSelected)} @@ -378,7 +303,9 @@ export const SidebarDesktop = ({
- +
+ +
)} {isLoggedIn && ( -
- -
- - -
+
+ +
)} From 27a69f7ebf7c0992fb71debe309ad626663709c5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 11 May 2026 16:34:11 +0300 Subject: [PATCH 06/34] fix(sidebar): complete color swap by recoloring active rail state The active rail indicator was still using bg-surface-float, which now matches the sidebar's own background, making the selected category invisible. Swap it to bg-background-default so the swap is symmetric and the rail remains readable. Co-authored-by: Cursor --- packages/shared/src/components/sidebar/SidebarDesktop.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index 2ea0899367..f14ea7d942 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -277,7 +277,7 @@ export const SidebarDesktop = ({ onClick={() => onSelectCategory(category.id)} className={classNames( railButtonClass, - isSelected && 'bg-surface-float text-text-primary', + isSelected && 'bg-background-default text-text-primary', )} > {category.icon(isSelected)} From 82d5fb918cd55f56bd30084395b9453fb4ab7d87 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 11 May 2026 16:38:11 +0300 Subject: [PATCH 07/34] style(sidebar): unify sidebar and feed bg on primary background The feed card is on bg-background-default, so put the sidebar on the same primary background for a single calm surface. The active rail pill now uses bg-surface-float so the selected category remains readable against the transparent rail. Co-authored-by: Cursor --- packages/shared/src/components/sidebar/SidebarDesktop.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index f14ea7d942..3ea98fffe5 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -236,7 +236,7 @@ export const SidebarDesktop = ({ onSelectCategory(category.id)} className={classNames( railButtonClass, - isSelected && 'bg-background-default text-text-primary', + isSelected && 'bg-surface-float text-text-primary', )} > {category.icon(isSelected)} From b3bd362c6c416656419aeb38215585828d64d4a7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 11 May 2026 16:44:01 +0300 Subject: [PATCH 08/34] style(sidebar): set sidebar surface to surface-float for contrast Move the sidebar back onto bg-surface-float so it reads as a distinct panel next to the primary-bg feed area. Active rail pill now uses bg-background-default so the selected category remains readable against the new surface-float rail. Co-authored-by: Cursor --- packages/shared/src/components/sidebar/SidebarDesktop.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index 3ea98fffe5..f14ea7d942 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -236,7 +236,7 @@ export const SidebarDesktop = ({ onSelectCategory(category.id)} className={classNames( railButtonClass, - isSelected && 'bg-surface-float text-text-primary', + isSelected && 'bg-background-default text-text-primary', )} > {category.icon(isSelected)} From 0dd9b97dabb5fa129a9d44ec7d2ba5f3012da396 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 10:05:00 +0300 Subject: [PATCH 09/34] style(sidebar): polish dual sidebar layout Refine the desktop dual-sidebar interaction model and move user actions into calmer rail/profile surfaces. Co-authored-by: Cursor --- packages/shared/src/components/MainLayout.tsx | 8 +- .../components/ProfileMenu/ProfileMenu.tsx | 10 +- .../ProfileMenu/ProfileMenuStats.tsx | 41 ++ .../ProfileMenu/sections/AccountSection.tsx | 10 +- .../src/components/cards/common/Card.tsx | 4 +- .../components/cards/common/list/ListCard.tsx | 2 +- .../components/feedback/FeedbackWidget.tsx | 41 +- .../notifications/NotificationsBell.tsx | 40 +- .../opportunity/OpportunityEntryButton.tsx | 53 ++- .../src/components/profile/ProfileButton.tsx | 65 ++- .../shared/src/components/sidebar/Section.tsx | 2 +- .../src/components/sidebar/Sidebar.spec.tsx | 12 +- .../shared/src/components/sidebar/Sidebar.tsx | 8 +- .../src/components/sidebar/SidebarDesktop.tsx | 410 ++++++++++++------ .../src/components/sidebar/SidebarItem.tsx | 2 +- .../shared/src/components/sidebar/common.tsx | 2 +- .../sidebar/sections/MainSection.tsx | 6 +- .../components/streak/ReadingStreakButton.tsx | 35 +- .../components/tooltips/InteractivePopup.tsx | 6 + .../webapp/public/assets/sidebar-app-icon.png | Bin 0 -> 9789 bytes 20 files changed, 579 insertions(+), 178 deletions(-) create mode 100644 packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx create mode 100644 packages/webapp/public/assets/sidebar-app-icon.png diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index e61ca501e8..0406c08ea7 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -179,7 +179,7 @@ function MainLayoutComponent({ } return ( -
+
{canGoBack && } {customBanner} {isBannerAvailable && } @@ -217,7 +217,9 @@ function MainLayoutComponent({ > {isAuthReady && showSidebar && ( {children}
- {!hideFeedbackWidget && } + {!hideFeedbackWidget && !sidebarOwnsHeader && }
); } diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx index 96b6ccc3cb..702bcbd194 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx @@ -15,10 +15,11 @@ import { TargetId } from '../../lib/log'; import { ProfileMenuFooter } from './ProfileMenuFooter'; import { UpgradeToPlus } from '../UpgradeToPlus'; import { ProfileMenuHeader } from './ProfileMenuHeader'; +import { ProfileMenuStats } from './ProfileMenuStats'; import { HorizontalSeparator } from '../utilities'; +import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton'; import { ProfileSection } from './ProfileSection'; -import { ResourceSection } from './sections/ResourceSection'; import { AccountSection } from './sections/AccountSection'; import { MainSection } from './sections/MainSection'; import { ThemeSection } from './sections/ThemeSection'; @@ -35,10 +36,12 @@ const ExtensionSection = dynamic(() => interface ProfileMenuProps { onClose: () => void; + position?: InteractivePopupPosition; } export default function ProfileMenu({ onClose, + position = InteractivePopupPosition.ProfileMenu, }: ProfileMenuProps): ReactElement | null { const { events } = useRouter(); const { user, logout } = useAuthContext(); @@ -62,11 +65,13 @@ export default function ProfileMenu({ {showProfileCompletion && } + + - diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx new file mode 100644 index 0000000000..42ea11c096 --- /dev/null +++ b/packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx @@ -0,0 +1,41 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ReputationUserBadge } from '../ReputationUserBadge'; +import { CoreIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useHasAccessToCores } from '../../hooks/useCoresFeature'; +import Link from '../utilities/Link'; +import { walletUrl } from '../../lib/constants'; +import { largeNumberFormat } from '../../lib'; + +export const ProfileMenuStats = (): ReactElement | null => { + const { user } = useAuthContext(); + const hasCoresAccess = useHasAccessToCores(); + + if (!user) { + return null; + } + + if (!hasCoresAccess && !user.reputation) { + return null; + } + + return ( +
+ + {hasCoresAccess && ( + + + + {largeNumberFormat(user.balance?.amount ?? 0)} + + + )} +
+ ); +}; diff --git a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx b/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx index cadda4998f..d51254b779 100644 --- a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx @@ -4,7 +4,6 @@ import { ProfileSection } from '../ProfileSection'; import { CreditCardIcon, InviteIcon, - SettingsIcon, TrendingIcon, OrganizationIcon, } from '../../icons'; @@ -16,9 +15,9 @@ import type { ProfileSectionItemProps } from '../ProfileSectionItem'; type AccountSectionProps = { /** - * Optional item rendered at the top of the section, above "Settings". + * Optional item rendered at the top of the section. * Used by ProfileMenu to surface the "Customize new tab" entry inline - * so it sits inside the same visual block as Settings instead of in a + * so it sits inside the same visual block as account links instead of in a * separate section. */ prepended?: ProfileSectionItemProps | null; @@ -32,11 +31,6 @@ export const AccountSection = ({ const items: ProfileSectionItemProps[] = [ ...(prepended ? [prepended] : []), - { - title: 'Settings', - href: `${settingsUrl}/profile`, - icon: SettingsIcon, - }, { title: 'Subscriptions', href: `${settingsUrl}/subscription`, diff --git a/packages/shared/src/components/cards/common/Card.tsx b/packages/shared/src/components/cards/common/Card.tsx index c3ccaf13ad..7a61668971 100644 --- a/packages/shared/src/components/cards/common/Card.tsx +++ b/packages/shared/src/components/cards/common/Card.tsx @@ -50,7 +50,7 @@ const clickableCardClasses = classNames( export const CardLink = classed('a', clickableCardClasses); const cardClassess = - 'snap-start relative max-h-cardLarge h-full flex flex-col p-0 rounded-16 border border-border-subtlest-tertiary hover:border-border-subtlest-secondary bg-background-subtle'; + 'snap-start relative max-h-cardLarge h-full flex flex-col p-0 rounded-16 border border-border-subtlest-quaternary hover:border-border-subtlest-tertiary bg-background-subtle'; export const Card = classed('article', styles.card, cardClassess); @@ -71,7 +71,7 @@ export const CardHeader = classed( export const ListCard = classed( 'article', styles.card, - 'relative flex items-stretch pt-4 pb-3 pr-4 rounded-16 bg-background-subtle border border-border-subtlest-tertiary hover:border-border-subtlest-secondary shadow-2', + 'relative flex items-stretch pt-4 pb-3 pr-4 rounded-16 bg-background-subtle border border-border-subtlest-quaternary hover:border-border-subtlest-tertiary shadow-2', ); export const ListCardDivider = classed( diff --git a/packages/shared/src/components/cards/common/list/ListCard.tsx b/packages/shared/src/components/cards/common/list/ListCard.tsx index c95db84911..69e9875936 100644 --- a/packages/shared/src/components/cards/common/list/ListCard.tsx +++ b/packages/shared/src/components/cards/common/list/ListCard.tsx @@ -51,7 +51,7 @@ export const CardLink = classed('a', clickableCardClasses); export const ListCard = classed( 'article', - `group relative w-full flex flex-col py-6 px-4 border-t border-border-subtlest-tertiary rounded-16 + `group relative w-full flex flex-col py-6 px-4 border-t border-border-subtlest-quaternary rounded-16 hover:bg-surface-float `, ); diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx index 21a691f3c9..c1189b59b6 100644 --- a/packages/shared/src/components/feedback/FeedbackWidget.tsx +++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx @@ -11,6 +11,10 @@ import { LazyModal } from '../modals/common/types'; import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; import { useCustomizeNewTab } from '../../features/customizeNewTab/CustomizeNewTabContext'; +interface FeedbackWidgetProps { + placement?: 'fixed' | 'sidebar'; +} + const TEAM_MEMBERS = [ { username: 'idoshamun', @@ -68,7 +72,9 @@ const getDailyTrio = (): ReadonlyArray<(typeof TEAM_MEMBERS)[number]> => { return [0, 1, 2].map((i) => TEAM_MEMBERS[(dayOfYear + i) % len]); }; -export function FeedbackWidget(): ReactElement | null { +export function FeedbackWidget({ + placement = 'fixed', +}: FeedbackWidgetProps): ReactElement | null { const { user } = useAuthContext(); const { showFeedbackButton } = useSettingsContext(); const isMobile = useViewSize(ViewSize.MobileL); @@ -100,6 +106,39 @@ export function FeedbackWidget(): ReactElement | null { return null; } + if (placement === 'sidebar') { + return ( + + ); + } + return ( + ) : compact ? ( + ) : (
{isStreaksEnabled && streak && ( @@ -273,7 +325,16 @@ export default function ProfileButton({
)} - {isOpen && onUpdate(false)} />} + {isOpen && ( + onUpdate(false)} + position={ + compact + ? InteractivePopupPosition.SidebarProfileMenu + : InteractivePopupPosition.ProfileMenu + } + /> + )} ); } diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx index 9bcb2a9086..03c3473aba 100644 --- a/packages/shared/src/components/sidebar/Section.tsx +++ b/packages/shared/src/components/sidebar/Section.tsx @@ -76,7 +76,7 @@ export function Section({ {/* Header content shown when sidebar is expanded */}
diff --git a/packages/shared/src/components/sidebar/Sidebar.spec.tsx b/packages/shared/src/components/sidebar/Sidebar.spec.tsx index ea4facca9c..e964e8058f 100644 --- a/packages/shared/src/components/sidebar/Sidebar.spec.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.spec.tsx @@ -43,6 +43,10 @@ jest.mock('../layout/HeaderLogo', () => ({ __esModule: true, default: () => null, })); +jest.mock('../cards/highlight/HighlightPostSidebarWidget', () => ({ + __esModule: true, + HighlightPostSidebarWidget: () => null, +})); let client: QueryClient; const updateAlerts = jest.fn(); @@ -127,9 +131,8 @@ it('should render the sidebar as open by default', async () => { name: 'Sidebar categories', }); expect(categoryRail).toBeInTheDocument(); - const section = await screen.findByText('Main'); - expect(section).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Main' })).toHaveAttribute( + expect(await screen.findByText('Explore')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Home' })).toHaveAttribute( 'aria-selected', 'true', ); @@ -206,11 +209,10 @@ describe('sidebar items', () => { ); it.each(discoverItems.map((item) => [item[0], item[1]]))( - 'it should expect %s to exist in discover', + 'it should expect %s to exist in home', async (name, href) => { renderComponent(); waitForNock(); - fireEvent.click(await screen.findByRole('tab', { name: 'Discover' })); const el = await screen.findByText(name); expect(el).toBeInTheDocument(); // eslint-disable-next-line testing-library/no-node-access diff --git a/packages/shared/src/components/sidebar/Sidebar.tsx b/packages/shared/src/components/sidebar/Sidebar.tsx index d049d3333a..68fdd3c707 100644 --- a/packages/shared/src/components/sidebar/Sidebar.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import dynamic from 'next/dynamic'; import { useViewSize, ViewSize } from '../../hooks'; @@ -18,13 +18,17 @@ const SidebarDesktop = dynamic(() => interface SidebarProps { activePage: string; + additionalButtons?: ReactNode; isNavButtons?: boolean; + showFeedbackWidget?: boolean; onNavTabClick?: (tab: string) => void; onLogoClick?: (e: React.MouseEvent) => unknown; } export const Sidebar = ({ + additionalButtons, isNavButtons, + showFeedbackWidget, onNavTabClick, onLogoClick, activePage, @@ -50,7 +54,9 @@ export const Sidebar = ({ // because router does not update there activePage={isExtension ? activePage : undefined} featureTheme={featureTheme} + additionalButtons={additionalButtons} isNavButtons={isNavButtons} + showFeedbackWidget={showFeedbackWidget} onNavTabClick={onNavTabClick} onLogoClick={onLogoClick} /> diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index f14ea7d942..ef6f79d400 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; import { Nav, SidebarAside, SidebarScrollWrapper } from './common'; @@ -10,34 +10,44 @@ import { MainSection } from './sections/MainSection'; import { CustomFeedSection } from './sections/CustomFeedSection'; import { DiscoverSection } from './sections/DiscoverSection'; import { CreatePostButton } from '../post/write'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ButtonIconPosition, ButtonSize } from '../buttons/Button'; import { BookmarkSection } from './sections/BookmarkSection'; import { NetworkSection } from './sections/NetworkSection'; import { HelpWidget } from '../help/HelpWidget'; import { BookmarkIcon, - HashtagIcon, + FeedbackIcon, HomeIcon, - HotIcon, - SourceIcon, SearchIcon, + SettingsIcon, SidebarArrowLeft, SidebarArrowRight, + SquadIcon, } from '../icons'; +import { fromCDN } from '../../lib/links'; import { IconSize } from '../Icon'; import { SidebarSelectedCategory, SidebarSettingsFlags, } from '../../graphql/settings'; import { Tooltip } from '../tooltip/Tooltip'; -import HeaderLogo from '../layout/HeaderLogo'; -import { LogoPosition } from '../Logo'; import { useSpotlight } from '../spotlight/useSpotlight'; import { useAuthContext } from '../../contexts/AuthContext'; import NotificationsBell from '../notifications/NotificationsBell'; -import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton'; import { QuestHeaderButton } from '../header/QuestHeaderButton'; import ProfileButton from '../profile/ProfileButton'; +import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; +import { ReadingStreakButton } from '../streak/ReadingStreakButton'; +import { useReadingStreak } from '../../hooks/streaks'; +import Link from '../utilities/Link'; +import { settingsUrl, webappUrl } from '../../lib/constants'; +import { FeedbackWidget } from '../feedback'; +import { isAppleDevice } from '../../lib/func'; +import InteractivePopup, { + InteractivePopupPosition, +} from '../tooltips/InteractivePopup'; +import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; +import { ResourceSection } from '../ProfileMenu/sections/ResourceSection'; type SidebarCategoryConfig = { id: SidebarSelectedCategory; @@ -48,23 +58,21 @@ type SidebarCategoryConfig = { const sidebarCategories: SidebarCategoryConfig[] = [ { id: SidebarSelectedCategory.Main, - label: 'Main', + label: 'Home', icon: (active) => ( - - ), - }, - { - id: SidebarSelectedCategory.Feeds, - label: 'Feeds', - icon: (active) => ( - + ), }, { id: SidebarSelectedCategory.Squads, label: 'Squads', icon: (active) => ( - + ), }, { @@ -74,13 +82,6 @@ const sidebarCategories: SidebarCategoryConfig[] = [ ), }, - { - id: SidebarSelectedCategory.Discover, - label: 'Discover', - icon: (active) => ( - - ), - }, ]; const getSidebarCategoryForPath = ( @@ -94,24 +95,82 @@ const getSidebarCategoryForPath = ( return SidebarSelectedCategory.Squads; } - if (activePage.includes('/feeds/')) { - return SidebarSelectedCategory.Feeds; + return SidebarSelectedCategory.Main; +}; + +const normalizeSidebarCategory = ( + category?: SidebarSelectedCategory, +): SidebarSelectedCategory => { + if (!category) { + return SidebarSelectedCategory.Main; } if ( - activePage.includes('/tags') || - activePage.includes('/sources') || - activePage.includes('/users') || - activePage.includes('/discussed') + category === SidebarSelectedCategory.Feeds || + category === SidebarSelectedCategory.Discover ) { - return SidebarSelectedCategory.Discover; + return SidebarSelectedCategory.Main; } - return SidebarSelectedCategory.Main; + return category; }; 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']; + +const SidebarSupportButton = (): ReactElement => { + const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); + + return ( + <> + + + + {isOpen && ( + onUpdate(false)} + position={InteractivePopupPosition.SidebarSupportMenu} + className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" + > + + + )} + + ); +}; + +const SidebarStreakButton = (): ReactElement | null => { + const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); + + if (!isStreaksEnabled || !streak) { + return null; + } + + return ( + + ); +}; type SidebarDesktopProps = { activePage?: string; @@ -120,15 +179,19 @@ type SidebarDesktopProps = { logoText?: string; }; isNavButtons?: boolean; + showFeedbackWidget?: boolean; onNavTabClick?: (tab: string) => void; onLogoClick?: (e: React.MouseEvent) => unknown; + additionalButtons?: ReactNode; }; export const SidebarDesktop = ({ activePage: activePageProp, featureTheme, isNavButtons, + showFeedbackWidget, onNavTabClick, onLogoClick, + additionalButtons, }: SidebarDesktopProps): ReactElement => { const router = useRouter(); const { flags, sidebarExpanded, toggleSidebarExpanded, updateFlag } = @@ -138,19 +201,29 @@ export const SidebarDesktop = ({ const { open: openSpotlight } = useSpotlight(); const { isLoggedIn } = useAuthContext(); const activePage = activePageProp || router.asPath || router.pathname; + const isFeedPage = activePage.includes('/feeds/'); const [selectedCategory, setSelectedCategory] = useState( - flags?.sidebarSelectedCategory ?? getSidebarCategoryForPath(activePage), + isFeedPage + ? SidebarSelectedCategory.Main + : normalizeSidebarCategory( + flags?.sidebarSelectedCategory ?? getSidebarCategoryForPath(activePage), + ), ); useEffect(() => { const activeCategory = getSidebarCategoryForPath(activePage); + if (isFeedPage) { + setSelectedCategory(SidebarSelectedCategory.Main); + return; + } + setSelectedCategory( activeCategory === SidebarSelectedCategory.Main - ? flags?.sidebarSelectedCategory ?? activeCategory + ? normalizeSidebarCategory(flags?.sidebarSelectedCategory) : activeCategory, ); - }, [activePage, flags?.sidebarSelectedCategory]); + }, [activePage, flags?.sidebarSelectedCategory, isFeedPage]); const defaultRenderSectionProps = useMemo( () => ({ @@ -177,17 +250,6 @@ export const SidebarDesktop = ({ }, [logEvent, sidebarExpanded, toggleSidebarExpanded]); const renderSelectedSection = (): ReactElement => { - if (selectedCategory === SidebarSelectedCategory.Feeds) { - return ( - - ); - } - if (selectedCategory === SidebarSelectedCategory.Squads) { return ( + + - ); - } - - return ( - + ); }; @@ -236,7 +300,7 @@ export const SidebarDesktop = ({ + + + {!sidebarExpanded && ( + + - + )} ); diff --git a/packages/shared/src/components/sidebar/SidebarItem.tsx b/packages/shared/src/components/sidebar/SidebarItem.tsx index c894127b39..54a080d38e 100644 --- a/packages/shared/src/components/sidebar/SidebarItem.tsx +++ b/packages/shared/src/components/sidebar/SidebarItem.tsx @@ -33,7 +33,7 @@ export const SidebarItem = ({ color={item.color} disableDefaultBackground={item.disableDefaultBackground} className={classNames( - 'mx-1 rounded-10', + 'mx-3 rounded-10', item.itemClassName, isCollapsed && 'justify-center', )} diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index b6a1619254..6dbffacf58 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -50,7 +50,7 @@ interface NavItemProps { } export const navBtnClass = - 'flex flex-1 items-center pl-2 laptop:pl-0 pr-5 laptop:pr-3 h-10 laptop:h-9 overflow-hidden'; + 'flex flex-1 items-center pl-2 laptop:pl-1 pr-5 laptop:pr-3 h-10 laptop:h-9 overflow-hidden'; export const SidebarAside = classed( 'aside', 'flex flex-col z-sidebarOverlay laptop:z-sidebar laptop:-translate-x-0 left-0 bg-background-default border-r border-border-subtlest-tertiary transition-[width,transform] duration-300 ease-in-out group fixed top-0 h-full', diff --git a/packages/shared/src/components/sidebar/sections/MainSection.tsx b/packages/shared/src/components/sidebar/sections/MainSection.tsx index 54b2f899a6..8227f624e6 100644 --- a/packages/shared/src/components/sidebar/sections/MainSection.tsx +++ b/packages/shared/src/components/sidebar/sections/MainSection.tsx @@ -9,12 +9,12 @@ import { HomeIcon, HotIcon, JoystickIcon, + MagicIcon, SquadIcon, MegaphoneIcon, YearInReviewIcon, } from '../../icons'; import { useAuthContext } from '../../../contexts/AuthContext'; -import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; import { OtherFeedPage } from '../../../lib/query'; import type { SidebarSectionProps } from './common'; import { @@ -74,8 +74,8 @@ export const MainSection = ({ path: myFeedPath, action: () => onNavTabClick?.(isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/'), - icon: () => ( - + icon: (active: boolean) => ( + } /> ), } : { diff --git a/packages/shared/src/components/streak/ReadingStreakButton.tsx b/packages/shared/src/components/streak/ReadingStreakButton.tsx index a957620833..b0886752f2 100644 --- a/packages/shared/src/components/streak/ReadingStreakButton.tsx +++ b/packages/shared/src/components/streak/ReadingStreakButton.tsx @@ -2,8 +2,12 @@ import type { ReactElement } from 'react'; import React, { useCallback, useState } from 'react'; import classnames from 'classnames'; import { ReadingStreakPopup } from './popup/ReadingStreakPopup'; -import type { ButtonIconPosition } from '../buttons/Button'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; import { ReadingStreakIcon, WarningIcon } from '../icons'; import { SimpleTooltip } from '../tooltips'; import type { UserStreak } from '../../graphql/users'; @@ -17,7 +21,7 @@ import ConditionalWrapper from '../ConditionalWrapper'; import type { TooltipPosition } from '../tooltips/BaseTooltipContainer'; import { useAuthContext } from '../../contexts/AuthContext'; import { isSameDayInTimezone } from '../../lib/timezones'; -import { IconWrapper } from '../Icon'; +import { IconSize, IconWrapper } from '../Icon'; import { useStreakTimezoneOk } from '../../hooks/streaks/useStreakTimezoneOk'; interface ReadingStreakButtonProps { @@ -25,6 +29,8 @@ interface ReadingStreakButtonProps { isLoading: boolean; compact?: boolean; iconPosition?: ButtonIconPosition; + iconSize?: IconSize; + appendTooltipToBody?: boolean; className?: string; } @@ -34,6 +40,7 @@ interface CustomStreaksTooltipProps { shouldShowStreaks?: boolean; setShouldShowStreaks?: (value: boolean) => void; placement: TooltipPosition; + appendTooltipToBody?: boolean; } function CustomStreaksTooltip({ @@ -42,6 +49,7 @@ function CustomStreaksTooltip({ shouldShowStreaks, setShouldShowStreaks, placement, + appendTooltipToBody, }: CustomStreaksTooltipProps): ReactElement { return ( document.body : undefined} + zIndex={1000} container={{ paddingClassName: 'p-0', bgClassName: 'bg-accent-pepper-subtlest', @@ -57,7 +67,7 @@ function CustomStreaksTooltip({ className: 'border border-border-subtlest-tertiary rounded-16', }} content={} - onClickOutside={() => setShouldShowStreaks(false)} + onClickOutside={() => setShouldShowStreaks?.(false)} > {children} @@ -68,9 +78,11 @@ export function ReadingStreakButton({ streak, isLoading, compact, - iconPosition, + iconPosition = ButtonIconPosition.Left, + iconSize, + appendTooltipToBody, className, -}: ReadingStreakButtonProps): ReactElement { +}: ReadingStreakButtonProps): ReactElement | null { const { logEvent } = useLogContext(); const { user } = useAuthContext(); const isLaptop = useViewSize(ViewSize.Laptop); @@ -78,6 +90,7 @@ export function ReadingStreakButton({ const [shouldShowStreaks, setShouldShowStreaks] = useState(false); const hasReadToday = streak?.lastViewAt && + user && isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone); const isTimezoneOk = useStreakTimezoneOk(); @@ -105,15 +118,16 @@ export function ReadingStreakButton({ <> ( + wrapper={(children) => ( - {children} + {children as ReactElement} )} > @@ -122,7 +136,10 @@ export function ReadingStreakButton({ type="button" iconPosition={iconPosition} icon={ - + {!isTimezoneOk && ( diff --git a/packages/shared/src/components/tooltips/InteractivePopup.tsx b/packages/shared/src/components/tooltips/InteractivePopup.tsx index b0a1cdb2b7..1e04e09d03 100644 --- a/packages/shared/src/components/tooltips/InteractivePopup.tsx +++ b/packages/shared/src/components/tooltips/InteractivePopup.tsx @@ -23,6 +23,8 @@ export enum InteractivePopupPosition { LeftCenter = 'leftCenter', LeftEnd = 'leftEnd', ProfileMenu = 'profileMenu', + SidebarProfileMenu = 'sidebarProfileMenu', + SidebarSupportMenu = 'sidebarSupportMenu', Screen = 'screen', } @@ -61,6 +63,8 @@ const positionClass: Record = { leftCenter: classNames(leftClass, centerClassY), leftEnd: classNames(leftClass, endClass), profileMenu: classNames(profileMenuRightClass, 'top-14'), + sidebarProfileMenu: 'left-[4.75rem] top-12', + sidebarSupportMenu: 'bottom-3 left-[4.75rem]', screen: 'inset-0 w-screen h-screen', }; @@ -142,6 +146,8 @@ function InteractivePopup({ {...props} > {finalPosition !== InteractivePopupPosition.ProfileMenu && + finalPosition !== InteractivePopupPosition.SidebarProfileMenu && + finalPosition !== InteractivePopupPosition.SidebarSupportMenu && onClose && ( + {feedSettingsButtonLabel} + + ) : ( + } + > + {feedSettingsButtonLabel} + + )} + {showToggleShortcuts && ( + <> + {shouldUseListFeedLayout ? ( + + ) : ( + + )} + )} ); diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index 41b5d0c98f..047008e1b8 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -5,6 +5,7 @@ import type { SetStateAction, } from 'react'; import React, { useContext } from 'react'; +import classNames from 'classnames'; import classed from '../../lib/classed'; import { SharedFeedPage } from '../utilities'; import MyFeedHeading from '../filters/MyFeedHeading'; @@ -34,7 +35,6 @@ import type { AllowedTags, TypographyProps } from '../typography/Typography'; import { Typography } from '../typography/Typography'; import { ToggleClickbaitShield } from '../buttons/ToggleClickbaitShield'; import { LogEvent, Origin } from '../../lib/log'; -import { AchievementTrackerButton } from '../filters/AchievementTrackerButton'; import { ActionType } from '../../graphql/actions'; import { BrowserName, @@ -106,13 +106,13 @@ export const SearchControlHeader = ({ className: { label: 'hidden', chevron: 'hidden', - button: '!px-1', + button: '!border-transparent !bg-transparent !px-1 hover:!bg-surface-hover', container: 'flex', }, shouldIndicateSelected: true, - buttonSize: isMobile ? ButtonSize.Small : ButtonSize.Medium, + buttonSize: ButtonSize.Small, iconOnly: true, - buttonVariant: isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary, + buttonVariant: ButtonVariant.Tertiary, }; const hasDismissedInstallExtension = checkHasCompleted( @@ -156,7 +156,21 @@ export const SearchControlHeader = ({ ); const primaryActions = [ - hasFeedActions && , + hasFeedActions && ( + + ), isUpvoted ? ( ), - hasFeedActions && , ]; const secondaryActions = [isLaptop && installExtensionButton]; const actions = primaryActions.filter(Boolean); @@ -213,8 +236,13 @@ export const SearchControlHeader = ({ ); }} > -
-
{actions}
+
+
{actions}
{sideActions.length > 0 && (
{sideActions}
)} diff --git a/packages/shared/src/components/modals/AchievementPickerModal.tsx b/packages/shared/src/components/modals/AchievementPickerModal.tsx index ddfc5e3e94..7f1916ba59 100644 --- a/packages/shared/src/components/modals/AchievementPickerModal.tsx +++ b/packages/shared/src/components/modals/AchievementPickerModal.tsx @@ -25,14 +25,20 @@ export interface AchievementPickerModalProps extends ModalProps { onUntrack: () => Promise; } -export const AchievementPickerModal = ({ +type AchievementPickerContentProps = Pick< + AchievementPickerModalProps, + 'achievements' | 'trackedAchievementId' | 'onTrack' | 'onUntrack' +> & { + origin?: string; +}; + +export const AchievementPickerContent = ({ achievements, trackedAchievementId, onTrack, onUntrack, - onRequestClose, - ...props -}: AchievementPickerModalProps): ReactElement => { + origin = 'picker_modal', +}: AchievementPickerContentProps): ReactElement => { const [isTracking, setIsTracking] = useState(false); const [isUntracking, setIsUntracking] = useState(false); const { logEvent } = useLogContext(); @@ -49,7 +55,7 @@ export const AchievementPickerModal = ({ event_name: LogEvent.TrackAchievement, target_type: TargetType.AchievementCard, target_id: achievementId, - extra: JSON.stringify({ origin: 'picker_modal' }), + extra: JSON.stringify({ origin }), }); } finally { setIsTracking(false); @@ -64,7 +70,7 @@ export const AchievementPickerModal = ({ event_name: LogEvent.UntrackAchievement, target_type: TargetType.AchievementCard, target_id: trackedAchievementId, - extra: JSON.stringify({ origin: 'picker_modal' }), + extra: JSON.stringify({ origin }), }); } finally { setIsUntracking(false); @@ -72,115 +78,129 @@ export const AchievementPickerModal = ({ }; return ( - - - - - Choose an achievement to track - - - Pick one to focus on next. - - - {lockedAchievements.length === 0 && ( - - You unlocked every achievement. - - )} + <> + + Choose an achievement to track + + + Pick one to focus on next. + - {lockedAchievements.length > 0 && ( -
- {lockedAchievements.map((userAchievement) => { - const target = getTargetCount(userAchievement.achievement); - const progressPercentage = Math.min( - (userAchievement.progress / target) * 100, - 100, - ); - const isTracked = - userAchievement.achievement.id === trackedAchievementId; + {lockedAchievements.length === 0 && ( + + You unlocked every achievement. + + )} - return ( -
-
- -
- - {userAchievement.achievement.name} - - - {userAchievement.achievement.description} - -
- -
+ {lockedAchievements.length > 0 && ( +
+ {lockedAchievements.map((userAchievement) => { + const target = getTargetCount(userAchievement.achievement); + const progressPercentage = Math.min( + (userAchievement.progress / target) * 100, + 100, + ); + const isTracked = + userAchievement.achievement.id === trackedAchievementId; -
+ return ( +
+
+ +
- {userAchievement.progress}/{target} + {userAchievement.achievement.name} - {userAchievement.achievement.points} pts + {userAchievement.achievement.description}
- + +
+ +
+ + {userAchievement.progress}/{target} + + + {userAchievement.achievement.points} pts +
- ); - })} -
- )} + +
+ ); + })} +
+ )} + + ); +}; + +export const AchievementPickerModal = ({ + achievements, + trackedAchievementId, + onTrack, + onUntrack, + onRequestClose, + ...props +}: AchievementPickerModalProps): ReactElement => { + return ( + + + + ); diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index a1b2dd22d3..336bf88550 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -8,11 +8,7 @@ import { EditIcon, DevCardIcon, EmbedIcon, - DocsIcon, - FeedbackIcon, AppIcon, - PrivacyIcon, - MegaphoneIcon, UserIcon, BlockIcon, CoinIcon, @@ -24,8 +20,6 @@ import { MailIcon, EyeIcon, NewTabIcon, - PhoneIcon, - ReputationLightningIcon, ExitIcon, OrganizationIcon, TrendingIcon, @@ -37,10 +31,6 @@ import { } from '../icons'; import { NavDrawer } from '../drawers/NavDrawer'; import { - appsUrl, - businessWebsiteUrl, - docs, - reputation, settingsUrl, walletUrl, webappUrl, @@ -287,45 +277,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { } as ProfileSectionItemPropsWithoutHref, }, }, - help: { - title: 'Help center', - items: { - feedback: { - title: 'Your Feedback', - icon: FeedbackIcon, - href: `${settingsUrl}/feedback`, - }, - privacy: { - title: 'Privacy', - icon: PrivacyIcon, - href: `${settingsUrl}/privacy`, - }, - reputation: { - title: 'Reputation', - icon: ReputationLightningIcon, - href: reputation, - external: true, - }, - advertise: { - title: 'Advertise', - icon: MegaphoneIcon, - href: businessWebsiteUrl, - external: true, - }, - apps: { - title: 'Apps', - icon: PhoneIcon, - href: appsUrl, - external: true, - }, - docs: { - title: 'Docs', - icon: DocsIcon, - href: docs, - external: true, - }, - }, - }, logout: { title: null, items: { diff --git a/packages/shared/src/components/quest/QuestButton.tsx b/packages/shared/src/components/quest/QuestButton.tsx index 1c2d8e7ed3..1c084657f6 100644 --- a/packages/shared/src/components/quest/QuestButton.tsx +++ b/packages/shared/src/components/quest/QuestButton.tsx @@ -1072,6 +1072,7 @@ const QuestLevelFireworkLayer = ({ interface QuestButtonProps { compact?: boolean; + panelOnly?: boolean; } interface QuestDropdownPanelProps { @@ -1224,6 +1225,7 @@ const QuestDropdownPanel = ({ export const QuestButton = ({ compact = false, + panelOnly = false, }: QuestButtonProps): ReactElement => { const router = useRouter(); const { optOutLevelSystem } = useSettingsContext(); @@ -1401,13 +1403,15 @@ export const QuestButton = ({ ); const scrollFadeRef = useScrollFade(); + const isPanelOpen = panelOnly || isOpen; + useEffect(() => { - if (!isOpen || !hasNewQuestRotations) { + if (!isPanelOpen || !hasNewQuestRotations) { return; } markQuestRotationsViewed(); - }, [hasNewQuestRotations, isOpen, markQuestRotationsViewed]); + }, [hasNewQuestRotations, isPanelOpen, markQuestRotationsViewed]); const clearProgressTimers = useCallback(() => { progressTimersRef.current.forEach((timerId) => { @@ -1764,6 +1768,42 @@ export const QuestButton = ({ }; }, [clearClaimedStampTimers, clearProgressTimers]); + if (panelOnly) { + return ( + <> +
+ +
+ {rewardFlightLayers.map((layer) => ( + handleRewardFlightLayerDone(layer.claimRotationId)} + /> + ))} + {levelFireworkParticles.length > 0 && ( + + )} + + ); + } + return ( <> diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index ef6f79d400..1f25cce8d8 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -18,6 +18,7 @@ import { BookmarkIcon, FeedbackIcon, HomeIcon, + JoystickIcon, SearchIcon, SettingsIcon, SidebarArrowLeft, @@ -34,7 +35,6 @@ import { Tooltip } from '../tooltip/Tooltip'; import { useSpotlight } from '../spotlight/useSpotlight'; import { useAuthContext } from '../../contexts/AuthContext'; import NotificationsBell from '../notifications/NotificationsBell'; -import { QuestHeaderButton } from '../header/QuestHeaderButton'; import ProfileButton from '../profile/ProfileButton'; import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; import { ReadingStreakButton } from '../streak/ReadingStreakButton'; @@ -48,6 +48,10 @@ import InteractivePopup, { } from '../tooltips/InteractivePopup'; import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; import { ResourceSection } from '../ProfileMenu/sections/ResourceSection'; +import { InnerProfileSettingsMenu } from '../profile/ProfileSettingsMenu'; +import { QuestButton } from '../quest/QuestButton'; +import { AchievementTrackerPanel } from '../filters/AchievementTrackerButton'; +import { Typography, TypographyType } from '../typography/Typography'; type SidebarCategoryConfig = { id: SidebarSelectedCategory; @@ -82,6 +86,13 @@ const sidebarCategories: SidebarCategoryConfig[] = [ ), }, + { + id: SidebarSelectedCategory.GameCenter, + label: 'Game Center', + icon: (active) => ( + + ), + }, ]; const getSidebarCategoryForPath = ( @@ -95,6 +106,14 @@ const getSidebarCategoryForPath = ( return SidebarSelectedCategory.Squads; } + if (activePage.includes('/settings')) { + return SidebarSelectedCategory.Settings; + } + + if (activePage.includes('/game-center')) { + return SidebarSelectedCategory.GameCenter; + } + return SidebarSelectedCategory.Main; }; @@ -107,7 +126,9 @@ const normalizeSidebarCategory = ( if ( category === SidebarSelectedCategory.Feeds || - category === SidebarSelectedCategory.Discover + category === SidebarSelectedCategory.Discover || + category === SidebarSelectedCategory.Settings || + category === SidebarSelectedCategory.GameCenter ) { return SidebarSelectedCategory.Main; } @@ -167,7 +188,7 @@ const SidebarStreakButton = (): ReactElement | null => { iconPosition={ButtonIconPosition.Right} iconSize={IconSize.Size16} appendTooltipToBody - className="h-7 rounded-10 px-2 hover:bg-surface-hover" + className="h-7 rounded-10 px-1.5 hover:bg-surface-hover" /> ); }; @@ -238,8 +259,11 @@ export const SidebarDesktop = ({ (category: SidebarSelectedCategory) => { setSelectedCategory(category); updateFlag(SidebarSettingsFlags.SelectedCategory, category); + if (category === SidebarSelectedCategory.GameCenter) { + router.push(`${webappUrl}game-center`).catch(() => undefined); + } }, - [updateFlag], + [router, updateFlag], ); const onToggleExpanded = useCallback(() => { @@ -270,6 +294,19 @@ export const SidebarDesktop = ({ ); } + if (selectedCategory === SidebarSelectedCategory.Settings) { + return ; + } + + if (selectedCategory === SidebarSelectedCategory.GameCenter) { + return ( +
+ + +
+ ); + } + return ( <> category.id === selectedCategory, )?.label; + const isSettingsSelected = + selectedCategory === SidebarSelectedCategory.Settings; + const isGameCenterSelected = + selectedCategory === SidebarSelectedCategory.GameCenter; + const isUtilityPanelSelected = isSettingsSelected || isGameCenterSelected; return ( onSelectCategory(category.id)} className={classNames( railButtonClass, - isSelected && 'bg-background-default text-text-primary', + isSelected && 'bg-background-default text-white', )} > {category.icon(isSelected)} @@ -403,15 +445,19 @@ export const SidebarDesktop = ({
+ onSelectCategory(SidebarSelectedCategory.Settings) + } > @@ -441,7 +487,7 @@ export const SidebarDesktop = ({ id="sidebar-context-panel" role="tabpanel" aria-labelledby={`sidebar-category-${selectedCategory}`} - aria-label={`${selectedLabel ?? selectedCategory} navigation`} + aria-label={`${selectedLabel ?? 'Settings'} navigation`} className={classNames( 'relative flex h-dvh min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-[opacity,width] duration-300', sidebarExpanded @@ -449,50 +495,57 @@ export const SidebarDesktop = ({ : 'pointer-events-none w-0 opacity-0', )} > -
- {isLoggedIn ? ( - - ) : ( -
- )} - -
- {isLoggedIn && } - - - - + {isSettingsSelected ? ( +
+ + Settings +
-
+ ) : ( +
+ {isLoggedIn ? ( + + ) : ( +
+ )} + +
+ {isLoggedIn && } + + + + +
+
+ )} -
- -
+ {!isUtilityPanelSelected && ( +
+ +
+ )} - {isLoggedIn && ( - <> -
- - {additionalButtons} -
- + {isLoggedIn && !isUtilityPanelSelected && additionalButtons && ( +
+ {additionalButtons} +
)} @@ -503,8 +556,8 @@ export const SidebarDesktop = ({ )} - - {showFeedbackWidget && ( + {!isUtilityPanelSelected && } + {showFeedbackWidget && !isUtilityPanelSelected && (
diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index 6dbffacf58..373be4f2fa 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -50,7 +50,7 @@ interface NavItemProps { } export const navBtnClass = - 'flex flex-1 items-center pl-2 laptop:pl-1 pr-5 laptop:pr-3 h-10 laptop:h-9 overflow-hidden'; + 'flex flex-1 items-center pl-1 laptop:pl-0 pr-5 laptop:pr-3 h-10 laptop:h-9 overflow-hidden'; export const SidebarAside = classed( 'aside', 'flex flex-col z-sidebarOverlay laptop:z-sidebar laptop:-translate-x-0 left-0 bg-background-default border-r border-border-subtlest-tertiary transition-[width,transform] duration-300 ease-in-out group fixed top-0 h-full', diff --git a/packages/shared/src/components/sidebar/sections/MainSection.tsx b/packages/shared/src/components/sidebar/sections/MainSection.tsx index 8227f624e6..1b77552bea 100644 --- a/packages/shared/src/components/sidebar/sections/MainSection.tsx +++ b/packages/shared/src/components/sidebar/sections/MainSection.tsx @@ -8,7 +8,6 @@ import { EyeIcon, HomeIcon, HotIcon, - JoystickIcon, MagicIcon, SquadIcon, MegaphoneIcon, @@ -17,11 +16,7 @@ import { import { useAuthContext } from '../../../contexts/AuthContext'; import { OtherFeedPage } from '../../../lib/query'; import type { SidebarSectionProps } from './common'; -import { - gameCenterMilestoneSectionId, - plusUrl, - webappUrl, -} from '../../../lib/constants'; +import { plusUrl, webappUrl } from '../../../lib/constants'; import useCustomDefaultFeed from '../../../hooks/feed/useCustomDefaultFeed'; import { SharedFeedPage } from '../../utilities'; import { isExtension } from '../../../lib/func'; @@ -30,8 +25,6 @@ import { featurePlusApiLanding, featureYearInReview, } from '../../../lib/featureManagement'; -import { useQuestDashboard } from '../../../hooks/useQuestDashboard'; -import { Typography, TypographyColor } from '../../typography/Typography'; export const MainSection = ({ isItemsButton, @@ -52,13 +45,6 @@ export const MainSection = ({ feature: featureYearInReview, shouldEvaluate: isLoggedIn, }); - const { data: questDashboard } = useQuestDashboard(); - const claimableMilestoneCount = useMemo( - () => - questDashboard?.milestone?.filter((quest) => quest.claimable).length ?? 0, - [questDashboard?.milestone], - ); - const menuItems: SidebarMenuItem[] = useMemo(() => { // this path can be opened on extension so it purposly // is not using webappUrl so it gets selected @@ -106,33 +92,6 @@ export const MainSection = ({ } : undefined; - const gameCenterPath = `${webappUrl}game-center${ - claimableMilestoneCount > 0 ? `#${gameCenterMilestoneSectionId}` : '' - }`; - - const gameCenter = isLoggedIn - ? { - icon: (active: boolean) => ( - } /> - ), - title: 'Game Center', - path: gameCenterPath, - isForcedLink: true, - requiresLogin: true, - ...(claimableMilestoneCount > 0 && { - rightIcon: () => ( - - {claimableMilestoneCount} - - ), - }), - } - : undefined; - const yearInReview = showYearInReview ? { icon: () => } />, @@ -184,13 +143,11 @@ export const MainSection = ({ isForcedLink: true, requiresLogin: true, }, - gameCenter, yearInReview, plusButton, ] as (SidebarMenuItem | undefined)[] ).filter((item): item is SidebarMenuItem => !!item); }, [ - claimableMilestoneCount, ctaCopy.full, isApiLanding, isCustomDefaultFeed, diff --git a/packages/shared/src/components/streak/ReadingStreakButton.tsx b/packages/shared/src/components/streak/ReadingStreakButton.tsx index b0886752f2..d6d7856cd0 100644 --- a/packages/shared/src/components/streak/ReadingStreakButton.tsx +++ b/packages/shared/src/components/streak/ReadingStreakButton.tsx @@ -151,7 +151,7 @@ export function ReadingStreakButton({ } onClick={handleToggle} className={classnames( - 'gap-1', + 'gap-0.5', compact && 'text-accent-bacon-default', className, )} diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index e8ea9c5b3c..494b9dc2e7 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -75,6 +75,8 @@ export enum SidebarSelectedCategory { Squads = 'squads', Saved = 'saved', Discover = 'discover', + Settings = 'settings', + GameCenter = 'gameCenter', } export type RemoteSettings = { diff --git a/packages/webapp/components/layouts/SettingsLayout/index.tsx b/packages/webapp/components/layouts/SettingsLayout/index.tsx index 406a21aea4..c46481a965 100644 --- a/packages/webapp/components/layouts/SettingsLayout/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/index.tsx @@ -56,12 +56,12 @@ const ProfileSettingsMenuDesktop = dynamic( export const navigationKey = generateQueryKey( RequestKey.AccountNavigation, - null, + undefined, ); export default function SettingsLayout({ children, -}: PropsWithChildren): ReactElement { +}: PropsWithChildren): ReactElement | null { const router = useRouter(); const { user: profile, isAuthReady } = useContext(AuthContext); const isMobile = useViewSize(ViewSize.MobileL); @@ -94,7 +94,7 @@ export default function SettingsLayout({
@@ -157,7 +157,7 @@ export default function SettingsLayout({ isOpen={isOpen} onClose={() => router.push(profile.permalink)} /> - ) : ( + ) : isLaptop ? null : ( )} {children} @@ -168,8 +168,8 @@ export default function SettingsLayout({ export const getSettingsLayout = (page: ReactNode): ReactNode => getFooterNavBarLayout( - getMainLayout({page}, null, { + getMainLayout({page}, undefined, { screenCentered: true, - showSidebar: false, + showSidebar: true, }), ); From 976247f79c055d22dd111fff281a232fe919b188 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 11:06:59 +0300 Subject: [PATCH 11/34] feat(sidebar): add dedicated Discover rail panel Promote Discover (Hot Takes, Tags, Sources, Leaderboard, Discussions) to a top-level rail entry with a HotIcon, matching the Squads/Saved/Game Center treatment. Drops the Discover list from the Home panel and routes /tags, /sources, /users, and /discussed back to the Discover category. Co-authored-by: Cursor --- .../src/components/sidebar/SidebarDesktop.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index 1f25cce8d8..3d1f3d6104 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -18,6 +18,7 @@ import { BookmarkIcon, FeedbackIcon, HomeIcon, + HotIcon, JoystickIcon, SearchIcon, SettingsIcon, @@ -79,6 +80,13 @@ const sidebarCategories: SidebarCategoryConfig[] = [ ), }, + { + id: SidebarSelectedCategory.Discover, + label: 'Discover', + icon: (active) => ( + + ), + }, { id: SidebarSelectedCategory.Saved, label: 'Saved', @@ -95,6 +103,8 @@ const sidebarCategories: SidebarCategoryConfig[] = [ }, ]; +const discoverPathFragments = ['/tags', '/sources', '/users', '/discussed']; + const getSidebarCategoryForPath = ( activePage: string, ): SidebarSelectedCategory => { @@ -114,6 +124,10 @@ const getSidebarCategoryForPath = ( return SidebarSelectedCategory.GameCenter; } + if (discoverPathFragments.some((path) => activePage.includes(path))) { + return SidebarSelectedCategory.Discover; + } + return SidebarSelectedCategory.Main; }; @@ -126,7 +140,6 @@ const normalizeSidebarCategory = ( if ( category === SidebarSelectedCategory.Feeds || - category === SidebarSelectedCategory.Discover || category === SidebarSelectedCategory.Settings || category === SidebarSelectedCategory.GameCenter ) { @@ -294,6 +307,16 @@ export const SidebarDesktop = ({ ); } + if (selectedCategory === SidebarSelectedCategory.Discover) { + return ( + + ); + } + if (selectedCategory === SidebarSelectedCategory.Settings) { return ; } @@ -320,11 +343,6 @@ export const SidebarDesktop = ({ title="Feeds" isItemsButton={false} /> - ); }; From 8b9bef107ba58d3b83866942fe09edab7b3f2792 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 11:10:23 +0300 Subject: [PATCH 12/34] feat(sidebar): add profile rail shortcut Adds a UserIcon rail entry that links straight to the signed-in user's profile page, matching the Settings/Support footer treatment without relying on the avatar image. Co-authored-by: Cursor --- .../src/components/sidebar/SidebarDesktop.tsx | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index 3d1f3d6104..f7900c0910 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -25,6 +25,7 @@ import { SidebarArrowLeft, SidebarArrowRight, SquadIcon, + UserIcon, } from '../icons'; import { fromCDN } from '../../lib/links'; import { IconSize } from '../Icon'; @@ -233,8 +234,11 @@ export const SidebarDesktop = ({ const { logEvent } = useLogContext(); const { isAvailable: isBannerAvailable } = useBanner(); const { open: openSpotlight } = useSpotlight(); - const { isLoggedIn } = useAuthContext(); + const { isLoggedIn, user } = useAuthContext(); const activePage = activePageProp || router.asPath || router.pathname; + const profileHref = user?.username ? `${webappUrl}${user.username}` : null; + const isProfileActive = + !!user?.username && activePage.includes(`/${user.username}`); const isFeedPage = activePage.includes('/feeds/'); const [selectedCategory, setSelectedCategory] = useState( isFeedPage @@ -459,6 +463,28 @@ export const SidebarDesktop = ({
+ {profileHref && ( + + + + )} +
From cc196537e3d557b32772aa88929331b64fe4c2ca Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 11:20:16 +0300 Subject: [PATCH 13/34] feat(sidebar): track recently visited feeds in Home panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Recent group (cap 5) that lists the last visited squad, tag, source, custom feed, and profile pages — Reddit-style — with kind-aware icons. Tracking is centralized in a module-level store so visits are captured even when a different sidebar panel is open. Post pages are intentionally excluded to keep the list focused on browsable feeds. Co-authored-by: Cursor --- .../src/components/sidebar/SidebarDesktop.tsx | 8 + .../sidebar/sections/RecentSection.tsx | 64 ++++++ packages/shared/src/graphql/settings.ts | 2 + .../shared/src/hooks/feed/useRecentPages.ts | 194 ++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 packages/shared/src/components/sidebar/sections/RecentSection.tsx create mode 100644 packages/shared/src/hooks/feed/useRecentPages.ts diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index f7900c0910..7545511daa 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -9,6 +9,7 @@ import { useBanner } from '../../hooks/useBanner'; import { MainSection } from './sections/MainSection'; import { CustomFeedSection } from './sections/CustomFeedSection'; import { DiscoverSection } from './sections/DiscoverSection'; +import { RecentSection } from './sections/RecentSection'; import { CreatePostButton } from '../post/write'; import { ButtonIconPosition, ButtonSize } from '../buttons/Button'; import { BookmarkSection } from './sections/BookmarkSection'; @@ -54,6 +55,7 @@ import { InnerProfileSettingsMenu } from '../profile/ProfileSettingsMenu'; import { QuestButton } from '../quest/QuestButton'; import { AchievementTrackerPanel } from '../filters/AchievementTrackerButton'; import { Typography, TypographyType } from '../typography/Typography'; +import { useRecentPagesTracker } from '../../hooks/feed/useRecentPages'; type SidebarCategoryConfig = { id: SidebarSelectedCategory; @@ -235,6 +237,7 @@ export const SidebarDesktop = ({ const { isAvailable: isBannerAvailable } = useBanner(); const { open: openSpotlight } = useSpotlight(); const { isLoggedIn, user } = useAuthContext(); + useRecentPagesTracker(); const activePage = activePageProp || router.asPath || router.pathname; const profileHref = user?.username ? `${webappUrl}${user.username}` : null; const isProfileActive = @@ -341,6 +344,11 @@ export const SidebarDesktop = ({ onNavTabClick={onNavTabClick} isItemsButton={isNavButtons ?? false} /> + +> = { + squad: SquadIcon, + tag: HashtagIcon, + source: EarthIcon, + feed: HotIcon, + user: UserIcon, +}; + +export const RecentSection = ({ + isItemsButton, + ...defaultRenderSectionProps +}: SidebarSectionProps): ReactElement | null => { + const pages = useRecentPages(); + + const menuItems: SidebarMenuItem[] = useMemo( + () => + pages.map((page) => { + const Icon = iconByKind[page.kind]; + const isActive = defaultRenderSectionProps.activePage === page.path; + return { + title: page.title, + path: page.path, + icon: () => } />, + active: isActive, + }; + }), + [pages, defaultRenderSectionProps.activePage], + ); + + if (menuItems.length === 0) { + return null; + } + + return ( +
+ ); +}; diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 494b9dc2e7..d4c3730cc9 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -43,6 +43,7 @@ export type SettingsFlags = { sidebarOtherExpanded: boolean; sidebarResourcesExpanded: boolean; sidebarBookmarksExpanded: boolean; + sidebarRecentExpanded: boolean; sidebarSelectedCategory?: SidebarSelectedCategory; clickbaitShieldEnabled: boolean; timezoneMismatchIgnore?: string; @@ -65,6 +66,7 @@ export enum SidebarSettingsFlags { OtherExpanded = 'sidebarOtherExpanded', ResourcesExpanded = 'sidebarResourcesExpanded', BookmarksExpanded = 'sidebarBookmarksExpanded', + RecentExpanded = 'sidebarRecentExpanded', SelectedCategory = 'sidebarSelectedCategory', ClickbaitShieldEnabled = 'clickbaitShieldEnabled', } diff --git a/packages/shared/src/hooks/feed/useRecentPages.ts b/packages/shared/src/hooks/feed/useRecentPages.ts new file mode 100644 index 0000000000..67da4c8dfa --- /dev/null +++ b/packages/shared/src/hooks/feed/useRecentPages.ts @@ -0,0 +1,194 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { get as getCache, set as setCache } from 'idb-keyval'; + +export const RECENT_PAGES_LIMIT = 5; +export const RECENT_PAGES_STORAGE_KEY = 'daily.recentPages'; + +export type RecentPageKind = 'squad' | 'tag' | 'source' | 'feed' | 'user'; + +export interface RecentPage { + path: string; + title: string; + kind: RecentPageKind; + visitedAt: number; +} + +type Detector = ( + query: Record, + asPath: string, +) => Pick | null; + +const firstValue = ( + value: string | string[] | undefined, +): string | undefined => { + if (Array.isArray(value)) { + return value[0]; + } + return value; +}; + +// Drop query/hash so e.g. ?openModal=... doesn't fragment a single feed into +// multiple recent entries. +const canonicalize = (asPath: string): string => asPath.split(/[?#]/)[0] ?? '/'; + +const detectors: Record = { + '/squads/[handle]': (query, asPath) => { + const handle = firstValue(query.handle); + if (!handle) { + return null; + } + return { path: canonicalize(asPath), title: `s/${handle}`, kind: 'squad' }; + }, + '/tags/[tag]': (query, asPath) => { + const tag = firstValue(query.tag); + if (!tag) { + return null; + } + return { path: canonicalize(asPath), title: `#${tag}`, kind: 'tag' }; + }, + '/sources/[source]': (query, asPath) => { + const source = firstValue(query.source); + if (!source) { + return null; + } + return { path: canonicalize(asPath), title: source, kind: 'source' }; + }, + '/feeds/[slugOrId]': (query, asPath) => { + const slugOrId = firstValue(query.slugOrId); + if (!slugOrId) { + return null; + } + return { path: canonicalize(asPath), title: slugOrId, kind: 'feed' }; + }, + '/[userId]': (query, asPath) => { + const userId = firstValue(query.userId); + if (!userId) { + return null; + } + return { path: canonicalize(asPath), title: `@${userId}`, kind: 'user' }; + }, +}; + +const detectRecentPage = ( + pathname: string, + query: Record, + asPath: string, +): Pick | null => { + const detector = detectors[pathname]; + if (!detector) { + return null; + } + return detector(query, asPath); +}; + +const sanitizeStoredPages = (value: unknown): RecentPage[] => { + if (!Array.isArray(value)) { + return []; + } + return value.filter( + (entry): entry is RecentPage => + !!entry && + typeof entry === 'object' && + typeof (entry as RecentPage).path === 'string' && + typeof (entry as RecentPage).title === 'string' && + typeof (entry as RecentPage).kind === 'string' && + typeof (entry as RecentPage).visitedAt === 'number', + ); +}; + +type Listener = (pages: RecentPage[]) => void; + +class RecentPagesStore { + private pages: RecentPage[] = []; + + private listeners = new Set(); + + private loaded = false; + + private loadPromise: Promise | null = null; + + load(): Promise { + if (this.loaded) { + return Promise.resolve(); + } + if (this.loadPromise) { + return this.loadPromise; + } + this.loadPromise = getCache(RECENT_PAGES_STORAGE_KEY) + .then((cached) => { + this.pages = sanitizeStoredPages(cached); + this.loaded = true; + this.notify(); + }) + .catch(() => { + this.loaded = true; + }); + return this.loadPromise; + } + + record(entry: Pick): void { + const next: RecentPage[] = [ + { ...entry, visitedAt: Date.now() }, + ...this.pages.filter((page) => page.path !== entry.path), + ].slice(0, RECENT_PAGES_LIMIT); + if ( + this.pages.length === next.length && + this.pages.every( + (page, index) => + page.path === next[index].path && + page.visitedAt === next[index].visitedAt, + ) + ) { + return; + } + this.pages = next; + setCache(RECENT_PAGES_STORAGE_KEY, next).catch(() => undefined); + this.notify(); + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + listener(this.pages); + return () => { + this.listeners.delete(listener); + }; + } + + private notify(): void { + this.listeners.forEach((listener) => listener(this.pages)); + } +} + +const recentPagesStore = new RecentPagesStore(); + +export const useRecentPagesTracker = (): void => { + const router = useRouter(); + + useEffect(() => { + recentPagesStore.load(); + }, []); + + useEffect(() => { + const entry = detectRecentPage( + router.pathname, + router.query, + router.asPath, + ); + if (!entry) { + return; + } + recentPagesStore.record(entry); + }, [router.pathname, router.query, router.asPath]); +}; + +export const useRecentPages = (): RecentPage[] => { + const [pages, setPages] = useState([]); + + useEffect(() => { + recentPagesStore.load(); + return recentPagesStore.subscribe(setPages); + }, []); + + return pages; +}; From 6d22c70624220cf459cf4e0f61bbfd6f97709633 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 11:26:24 +0300 Subject: [PATCH 14/34] fix(feed): style cards-layout actions strip as a header The Feed Settings / sort / clickbait controls render through the non-search, non-list path of FeedContainer, which my previous header tweak did not touch. Promote that strip to a real
with a slim bottom border and compact horizontal padding so it reads as a header on the dual-sidebar layout instead of floating above the grid. Co-authored-by: Cursor --- .../shared/src/components/feeds/FeedContainer.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index 9374b18e81..503cf4ccba 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -271,19 +271,21 @@ export const FeedContainer = ({ {inlineHeader && header} {topContent} {isSearch && !shouldUseListFeedLayout && ( - {!!actionButtons && ( - + {actionButtons} )} {shortcuts} - +
)} Date: Tue, 12 May 2026 11:31:38 +0300 Subject: [PATCH 15/34] refactor(sidebar): unify utility panel header Replaces the profile/streak/CreatePost/feedback chrome on Squads, Saved, Discover, Game Center, and Settings with a slim generic header showing the panel title on the left and a close button on the right. Squads gets a + action moved out of the section title into the panel header. Feedback and Help widgets now render on the Home panel only. Co-authored-by: Cursor --- .../src/components/sidebar/SidebarDesktop.tsx | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index 7545511daa..31d284d93d 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -21,6 +21,7 @@ import { HomeIcon, HotIcon, JoystickIcon, + PlusIcon, SearchIcon, SettingsIcon, SidebarArrowLeft, @@ -28,6 +29,8 @@ import { SquadIcon, UserIcon, } from '../icons'; +import { useSquadNavigation } from '../../hooks'; +import { Origin } from '../../lib/log'; import { fromCDN } from '../../lib/links'; import { IconSize } from '../Icon'; import { @@ -236,6 +239,7 @@ export const SidebarDesktop = ({ const { logEvent } = useLogContext(); const { isAvailable: isBannerAvailable } = useBanner(); const { open: openSpotlight } = useSpotlight(); + const { openNewSquad } = useSquadNavigation(); const { isLoggedIn, user } = useAuthContext(); useRecentPagesTracker(); const activePage = activePageProp || router.asPath || router.pathname; @@ -298,7 +302,6 @@ export const SidebarDesktop = ({ return ( ); @@ -308,7 +311,6 @@ export const SidebarDesktop = ({ return ( ); @@ -318,7 +320,6 @@ export const SidebarDesktop = ({ return ( ); @@ -364,9 +365,15 @@ export const SidebarDesktop = ({ )?.label; const isSettingsSelected = selectedCategory === SidebarSelectedCategory.Settings; - const isGameCenterSelected = - selectedCategory === SidebarSelectedCategory.GameCenter; - const isUtilityPanelSelected = isSettingsSelected || isGameCenterSelected; + const isHomePanel = selectedCategory === SidebarSelectedCategory.Main; + const isSquadsPanel = selectedCategory === SidebarSelectedCategory.Squads; + // Anything that's not the personalised Home panel gets the slim + // generic header treatment (title left, optional add + close right) and + // skips the profile chrome, create-post, and feedback widget. + const isUtilityPanelSelected = !isHomePanel; + const utilityPanelTitle = isSettingsSelected + ? 'Settings' + : selectedLabel ?? ''; return ( - {isSettingsSelected ? ( -
- - Settings - -
- ) : ( + {isHomePanel ? (
{isLoggedIn ? ( @@ -564,6 +565,36 @@ export const SidebarDesktop = ({
{isLoggedIn && } + + + +
+
+ ) : ( +
+ + {utilityPanelTitle} + +
+ {isSquadsPanel && ( + + + + )}
- -
{profileHref && (
@@ -499,7 +496,9 @@ export const SidebarDesktop = ({
)} +
+
From 8ceec0a410291ebe2c7db355519c0186fc5614b8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 14:53:10 +0300 Subject: [PATCH 17/34] fix(settings): persist client-only sidebar flags locally The daily-api `SettingsFlagsPublicInput` schema doesn't define `sidebarSelectedCategory` or `sidebarRecentExpanded`, so including them in the `updateUserSettings` payload causes the mutation to fail with a generic "Unexpected error". Mark them as client-only flags, strip them from the GraphQL payload, and round-trip them through localStorage so the sidebar panel selection and recent-expansion still survive reloads. Co-authored-by: Cursor --- .../shared/src/contexts/SettingsContext.tsx | 103 +++++++++++++++++- packages/shared/src/graphql/settings.ts | 13 +++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index 694fc0b56c..4281dd5bb2 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import { useMutation } from '@tanstack/react-query'; import type { + ClientOnlySettingsFlag, RemoteSettings, RemoteTheme, SettingsFlags, @@ -15,6 +16,7 @@ import type { } from '../graphql/settings'; import { CampaignCtaPlacement, + CLIENT_ONLY_SETTINGS_FLAGS, SidebarSelectedCategory, UPDATE_USER_SETTINGS_MUTATION, } from '../graphql/settings'; @@ -120,6 +122,64 @@ export type SettingsContextProviderProps = { loadedSettings?: boolean; }; +const CLIENT_ONLY_FLAGS_STORAGE_KEY = 'settings:clientOnlyFlags'; + +const clientOnlyFlagsSet: ReadonlySet = new Set( + CLIENT_ONLY_SETTINGS_FLAGS, +); + +const isClientOnlyFlag = (key: string): key is ClientOnlySettingsFlag => + clientOnlyFlagsSet.has(key); + +const splitFlags = ( + flags: SettingsFlags | undefined, +): { remote: SettingsFlags | undefined; local: Partial } => { + if (!flags) { + return { remote: undefined, local: {} }; + } + + const remote: Record = {}; + const local: Record = {}; + Object.entries(flags).forEach(([key, value]) => { + if (isClientOnlyFlag(key)) { + local[key] = value; + return; + } + remote[key] = value; + }); + return { + remote: remote as SettingsFlags, + local: local as Partial, + }; +}; + +const readLocalFlags = (): Partial => { + const raw = storageWrapper.getItem(CLIENT_ONLY_FLAGS_STORAGE_KEY); + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw) as Record; + const local: Record = {}; + Object.entries(parsed).forEach(([key, value]) => { + if (isClientOnlyFlag(key)) { + local[key] = value; + } + }); + return local as Partial; + } catch { + return {}; + } +}; + +const writeLocalFlags = (flags: Partial): void => { + if (!Object.keys(flags).length) { + storageWrapper.removeItem(CLIENT_ONLY_FLAGS_STORAGE_KEY); + return; + } + storageWrapper.setItem(CLIENT_ONLY_FLAGS_STORAGE_KEY, JSON.stringify(flags)); +}; + const defaultSettings: RemoteSettings = { spaciness: 'eco', openNewTab: true, @@ -177,10 +237,15 @@ export const SettingsContextProvider = ({ unknown, RemoteSettings >({ - mutationFn: (params) => - gqlClient.request(UPDATE_USER_SETTINGS_MUTATION, { - data: params, - }), + mutationFn: (params) => { + const { remote } = splitFlags(params.flags); + const remotePayload: RemoteSettings = remote + ? { ...params, flags: remote } + : params; + return gqlClient.request(UPDATE_USER_SETTINGS_MUTATION, { + data: remotePayload, + }); + }, onError: (_, params) => { const rollback = Object.keys(params).reduce( @@ -212,6 +277,36 @@ export const SettingsContextProvider = ({ } }, []); + const didHydrateClientFlagsRef = useRef(false); + useEffect(() => { + if (didHydrateClientFlagsRef.current) { + return; + } + didHydrateClientFlagsRef.current = true; + const stored = readLocalFlags(); + const missingEntries = Object.entries(stored).filter( + ([key]) => settings.flags?.[key as keyof SettingsFlags] === undefined, + ); + if (!missingEntries.length) { + return; + } + updateSettings({ + ...settings, + flags: { + ...settings.flags, + ...Object.fromEntries(missingEntries), + }, + }); + // Run only once on mount; we intentionally avoid re-hydrating after + // the user mutates client-only flags. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const { local } = splitFlags(settings.flags); + writeLocalFlags(local); + }, [settings.flags]); + const updateRemoteSettingsFn = async ( newSettings: RemoteSettings, bootUserId?: string, diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index d4c3730cc9..27aa3f6fdb 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -71,12 +71,25 @@ export enum SidebarSettingsFlags { ClickbaitShieldEnabled = 'clickbaitShieldEnabled', } +// Flag keys that the frontend manages locally only — the daily-api +// `SettingsFlagsPublicInput` schema does not accept these, so sending +// them via `updateUserSettings` makes the GraphQL server reject the +// whole mutation. They are persisted to localStorage instead and +// stripped from the remote payload before the request is dispatched. +export const CLIENT_ONLY_SETTINGS_FLAGS = [ + 'sidebarSelectedCategory', + 'sidebarRecentExpanded', +] as const satisfies ReadonlyArray; + +export type ClientOnlySettingsFlag = (typeof CLIENT_ONLY_SETTINGS_FLAGS)[number]; + export enum SidebarSelectedCategory { Main = 'main', Feeds = 'feeds', Squads = 'squads', Saved = 'saved', Discover = 'discover', + Profile = 'profile', Settings = 'settings', GameCenter = 'gameCenter', } From 6e911b9ee0f03d86c7f35e2a189182551a7adab4 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 14:53:17 +0300 Subject: [PATCH 18/34] refactor(profile-menu): move profile entries into sidebar Profile panel Drop the legacy MainSection/AccountSection blocks from the ProfileMenu dropdown and surface those destinations through a dedicated Profile panel in the desktop sidebar rail. The ProfileMenu now keeps only the extension-specific actions, theme + account chrome moves into the profile settings menu, and the redundant `profileMenu` variant of OpportunityEntryButton is removed alongside it. Co-authored-by: Cursor --- .../components/ProfileMenu/ProfileMenu.tsx | 28 ++---- .../ProfileMenu/sections/AccountSection.tsx | 62 ------------ .../ProfileMenu/sections/ExtensionSection.tsx | 59 +++++------ .../ProfileMenu/sections/MainSection.tsx | 67 ------------- .../opportunity/OpportunityEntryButton.tsx | 43 +------- .../profile/ProfileSettingsMenu.tsx | 3 + .../src/components/sidebar/SidebarDesktop.tsx | 74 +++++++++----- .../sidebar/sections/ProfileSection.tsx | 97 +++++++++++++++++++ 8 files changed, 181 insertions(+), 252 deletions(-) delete mode 100644 packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx delete mode 100644 packages/shared/src/components/ProfileMenu/sections/MainSection.tsx create mode 100644 packages/shared/src/components/sidebar/sections/ProfileSection.tsx diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx index 774aafd449..b5d04c93fd 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx @@ -12,16 +12,12 @@ import { checkIsExtension } from '../../lib/func'; import { LogoutReason } from '../../lib/user'; import { TargetId } from '../../lib/log'; -import { ProfileMenuFooter } from './ProfileMenuFooter'; import { UpgradeToPlus } from '../UpgradeToPlus'; import { ProfileMenuHeader } from './ProfileMenuHeader'; import { ProfileMenuStats } from './ProfileMenuStats'; import { HorizontalSeparator } from '../utilities'; import { ProfileSection } from './ProfileSection'; -import { AccountSection } from './sections/AccountSection'; -import { MainSection } from './sections/MainSection'; -import { ThemeSection } from './sections/ThemeSection'; import { FeedbackButtonSection } from './sections/FeedbackButtonSection'; import { useCustomizeNewTabMenuItem } from './sections/ExtensionSection'; import { ProfileCompletion } from '../../features/profile/components/ProfileWidgets/ProfileCompletion'; @@ -80,19 +76,15 @@ export default function ProfileMenu({ - - ); } diff --git a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx b/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx deleted file mode 100644 index d51254b779..0000000000 --- a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import type { ReactElement } from 'react'; -import { ProfileSection } from '../ProfileSection'; -import { - CreditCardIcon, - InviteIcon, - TrendingIcon, - OrganizationIcon, -} from '../../icons'; -import { settingsUrl } from '../../../lib/constants'; -import { useLazyModal } from '../../../hooks/useLazyModal'; -import { LazyModal } from '../../modals/common/types'; -import { useCanPurchaseCores } from '../../../hooks/useCoresFeature'; -import type { ProfileSectionItemProps } from '../ProfileSectionItem'; - -type AccountSectionProps = { - /** - * Optional item rendered at the top of the section. - * Used by ProfileMenu to surface the "Customize new tab" entry inline - * so it sits inside the same visual block as account links instead of in a - * separate section. - */ - prepended?: ProfileSectionItemProps | null; -}; - -export const AccountSection = ({ - prepended, -}: AccountSectionProps = {}): ReactElement => { - const { openModal } = useLazyModal(); - const canBuy = useCanPurchaseCores(); - - const items: ProfileSectionItemProps[] = [ - ...(prepended ? [prepended] : []), - { - title: 'Subscriptions', - href: `${settingsUrl}/subscription`, - icon: CreditCardIcon, - }, - { - title: 'Organizations', - href: `${settingsUrl}/organization`, - icon: OrganizationIcon, - }, - { - title: 'Invite friends', - href: `${settingsUrl}/invite`, - icon: InviteIcon, - }, - ]; - - if (canBuy) { - items.push({ - title: 'Ads dashboard', - icon: TrendingIcon, - onClick: () => { - openModal({ type: LazyModal.AdsDashboard }); - }, - }); - } - - return ; -}; diff --git a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx index b1d60cbb38..b88fb29f78 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type { ReactElement } from 'react'; -import { HorizontalSeparator } from '../../utilities'; import { ProfileSection } from '../ProfileSection'; import { useDndContext } from '../../../contexts/DndContext'; import { useSettingsContext } from '../../../contexts/SettingsContext'; @@ -21,19 +20,10 @@ import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent, TargetType } from '../../../lib/log'; import type { ProfileSectionItemProps } from '../ProfileSectionItem'; -export type ExtensionSectionProps = { - /** - * Called after the user picks any item in this section so the parent - * ProfileMenu collapses the dropdown. - */ - onClose?: () => void; -}; - /** * Hook returning the "Customize new tab" item when the feature flag is - * on, or `null` when it isn't. AccountSection consumes this to prepend - * the entry directly above "Settings" — placing it inside the existing - * section instead of giving it its own visual block. + * on, or `null` when it isn't. The ProfileMenu renders the entry as its + * own one-item section, sitting above the legacy ExtensionSection block. */ export const useCustomizeNewTabMenuItem = ( onClose?: () => void, @@ -63,8 +53,8 @@ export const useCustomizeNewTabMenuItem = ( /** * Legacy fallback for users in the control bucket of the customize * sidebar feature flag. When the flag is on, the customize entry is - * folded into AccountSection via `useCustomizeNewTabMenuItem` and this - * component renders nothing. + * surfaced via `useCustomizeNewTabMenuItem` and this component renders + * nothing. */ export const ExtensionSection = (): ReactElement | null => { const { isEnabled: isCustomizerEnabled } = useCustomizeNewTab(); @@ -81,27 +71,24 @@ export const ExtensionSection = (): ReactElement | null => { } return ( - <> - - openModal({ type: shortcutsModal }), - }, - { - title: `${isDndActive ? 'Resume' : 'Pause'} new tab`, - icon: isDndActive ? PlayIcon : PauseIcon, - onClick: () => setShowDnd?.(true), - }, - { - title: `${optOutCompanion ? 'Enable' : 'Disable'} companion widget`, - icon: () => , - onClick: () => toggleOptOutCompanion(), - }, - ]} - /> - + openModal({ type: shortcutsModal }), + }, + { + title: `${isDndActive ? 'Resume' : 'Pause'} new tab`, + icon: isDndActive ? PlayIcon : PauseIcon, + onClick: () => setShowDnd?.(true), + }, + { + title: `${optOutCompanion ? 'Enable' : 'Disable'} companion widget`, + icon: () => , + onClick: () => toggleOptOutCompanion(), + }, + ]} + /> ); }; diff --git a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx deleted file mode 100644 index dcb40ec83b..0000000000 --- a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import type { ReactElement } from 'react'; - -import type { ProfileSectionItemProps } from '../ProfileSectionItem'; -import { ProfileSectionItem } from '../ProfileSectionItem'; -import { - AnalyticsIcon, - CoinIcon, - DevCardIcon, - MedalBadgeIcon, - UserIcon, -} from '../../icons'; -import { settingsUrl, walletUrl, webappUrl } from '../../../lib/constants'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { useHasAccessToCores } from '../../../hooks/useCoresFeature'; -import { OpportunityEntryButton } from '../../opportunity/OpportunityEntryButton'; - -export const MainSection = (): ReactElement => { - const hasAccessToCores = useHasAccessToCores(); - const { user } = useAuthContext(); - - const profileItem: ProfileSectionItemProps = { - title: 'Your profile', - href: `${webappUrl}${user?.username}`, - icon: UserIcon, - }; - - const items: ProfileSectionItemProps[] = [ - ...(hasAccessToCores - ? [ - { - title: 'Core wallet', - href: walletUrl, - icon: CoinIcon, - } satisfies ProfileSectionItemProps, - ] - : []), - { - title: 'Achievements', - href: `${webappUrl}${user?.username}/achievements`, - icon: MedalBadgeIcon, - }, - { - title: 'DevCard', - href: `${settingsUrl}/customization/devcard`, - icon: DevCardIcon, - }, - { - title: 'Analytics', - href: `${webappUrl}analytics`, - icon: AnalyticsIcon, - }, - ]; - - return ( -
- - - {items.map((item) => ( - - ))} -
- ); -}; diff --git a/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx b/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx index 4049b56e1c..f2ccf55187 100644 --- a/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx +++ b/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx @@ -13,13 +13,6 @@ import { NewOpportunityPopover } from './NewOpportunityPopover'; import { useLogOpportunityNudgeClick } from '../../hooks/log/useLogOpportunityNudgeClick'; import { useActions } from '../../hooks'; import { ActionType } from '../../graphql/actions'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../typography/Typography'; -import { IconSize } from '../Icon'; const OpportunityTooltip = ({ children, @@ -46,13 +39,7 @@ const OpportunityTooltip = ({ ); }; -type OpportunityEntryButtonProps = { - variant?: 'icon' | 'profileMenu'; -}; - -export const OpportunityEntryButton = ({ - variant = 'icon', -}: OpportunityEntryButtonProps) => { +export const OpportunityEntryButton = () => { const { alerts } = useAlertsContext(); const hasOpportunityAlert = !!alerts.opportunityId; const { checkHasCompleted } = useActions(); @@ -68,34 +55,6 @@ export const OpportunityEntryButton = ({ hasOpportunityAlert ? alerts.opportunityId : '' }`; - if (variant === 'profileMenu') { - return ( - -
- - - tag={TypographyTag.Link} - color={TypographyColor.Tertiary} - type={TypographyType.Subhead} - className="flex h-8 cursor-pointer items-center gap-2 rounded-10 px-1 hover:bg-surface-float" - onClick={logOpportunityNudgeClick} - > - - - {hasOpportunityAlert && ( - - 1 - - )} - - Jobs - - -
-
- ); - } - return (
diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index 336bf88550..841be6b1d1 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -47,6 +47,7 @@ import type { WithClassNameProps } from '../utilities'; import { HorizontalSeparator } from '../utilities'; import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; import { ProfileMenuHeader } from '../ProfileMenu/ProfileMenuHeader'; +import { ThemeSection } from '../ProfileMenu/sections/ThemeSection'; import { ProfileImageSize } from '../ProfilePicture'; import { useViewSize, ViewSize } from '../../hooks'; import { TypographyColor, TypographyType } from '../typography/Typography'; @@ -311,6 +312,8 @@ export const InnerProfileSettingsMenu = ({ return (
diff --git a/packages/shared/src/components/sidebar/sections/ProfileSection.tsx b/packages/shared/src/components/sidebar/sections/ProfileSection.tsx new file mode 100644 index 0000000000..d7e78b3417 --- /dev/null +++ b/packages/shared/src/components/sidebar/sections/ProfileSection.tsx @@ -0,0 +1,97 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import type { SidebarMenuItem } from '../common'; +import { ListIcon } from '../common'; +import { + AnalyticsIcon, + CoinIcon, + DevCardIcon, + JobIcon, + MedalBadgeIcon, + UserIcon, +} from '../../icons'; +import { Section } from '../Section'; +import type { SidebarSectionProps } from './common'; +import { settingsUrl, walletUrl, webappUrl } from '../../../lib/constants'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useHasAccessToCores } from '../../../hooks/useCoresFeature'; +import { useAlertsContext } from '../../../contexts/AlertContext'; +import { useLogOpportunityNudgeClick } from '../../../hooks/log/useLogOpportunityNudgeClick'; + +export const ProfileSection = ({ + isItemsButton, + ...defaultRenderSectionProps +}: SidebarSectionProps): ReactElement | null => { + const hasAccessToCores = useHasAccessToCores(); + const { user } = useAuthContext(); + const { alerts } = useAlertsContext(); + const logOpportunityNudgeClick = useLogOpportunityNudgeClick(); + + const menuItems: SidebarMenuItem[] = useMemo(() => { + if (!user?.username) { + return []; + } + + return [ + { + title: 'Your profile', + path: `${webappUrl}${user.username}`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Jobs', + path: `${webappUrl}jobs/${alerts.opportunityId ?? ''}`, + action: logOpportunityNudgeClick, + icon: (active: boolean) => ( + } /> + ), + }, + ...(hasAccessToCores + ? [ + { + title: 'Core wallet', + path: walletUrl, + icon: (active: boolean) => ( + } /> + ), + }, + ] + : []), + { + title: 'Achievements', + path: `${webappUrl}${user.username}/achievements`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'DevCard', + path: `${settingsUrl}/customization/devcard`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Analytics', + path: `${webappUrl}analytics`, + icon: (active: boolean) => ( + } /> + ), + }, + ]; + }, [alerts.opportunityId, hasAccessToCores, logOpportunityNudgeClick, user]); + + if (menuItems.length === 0) { + return null; + } + + return ( +
+ ); +}; From 4f0d3a77423c7059096e84704b5a7321eed90689 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 14:53:25 +0300 Subject: [PATCH 19/34] style(feed): tighten list-frame header and search controls Treat the list feed action strip as a real header (consistent padding, quaternary border, inset grid) and shrink the search-control buttons to a uniform compact icon size so the toolbar reads as one row. Co-authored-by: Cursor --- .../shared/src/components/MainFeedLayout.tsx | 3 --- .../buttons/ToggleClickbaitShield.tsx | 14 +++++++---- .../src/components/feeds/FeedContainer.tsx | 13 ++++++---- .../src/components/filters/MyFeedHeading.tsx | 9 +++++-- .../shared/src/components/layout/common.tsx | 24 ++++++++++--------- .../src/components/utilities/common.tsx | 7 +----- 6 files changed, 39 insertions(+), 31 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 6122902759..d8f9c2b613 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -730,9 +730,6 @@ export default function MainFeedLayout({ {...feedProps} shortcuts={shortcuts} disableBriefCard={disableBriefCard} - className={classNames( - shouldUseListFeedLayout && !isFinder && 'laptop:px-6', - )} /> ) )} diff --git a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx index 7fa5060f68..cfb0924286 100644 --- a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx +++ b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx @@ -16,6 +16,7 @@ import { SidebarSettingsFlags } from '../../graphql/settings'; import { useLogContext } from '../../contexts/LogContext'; import type { Origin } from '../../lib/log'; import { LogEvent, TargetId } from '../../lib/log'; +import type { IconProps } from '../Icon'; import { useActiveFeedContext } from '../../contexts/ActiveFeedContext'; import { useAuthContext } from '../../contexts/AuthContext'; import { webappUrl } from '../../lib/constants'; @@ -25,9 +26,11 @@ import { Tooltip } from '../tooltip/Tooltip'; export const ToggleClickbaitShield = ({ origin, buttonProps = {}, + iconSize, }: { origin: Origin; buttonProps?: ButtonProps<'button'>; + iconSize?: IconProps['size']; }): ReactElement => { const queryClient = useQueryClient(); const { queryKey: feedQueryKey } = useActiveFeedContext(); @@ -62,9 +65,12 @@ export const ToggleClickbaitShield = ({ {...commonIconProps} icon={ hasUsedFreeTrial ? ( - + ) : ( - + ) } onClick={() => { @@ -93,9 +99,9 @@ export const ToggleClickbaitShield = ({ {...commonIconProps} icon={ isClickbaitShieldEnabled ? ( - + ) : ( - + ) } loading={loading} diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index 503cf4ccba..2289f60bdd 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -139,7 +139,7 @@ export const FeedContainer = ({ const isLaptop = useViewSize(ViewSize.Laptop); const { feedName } = useActiveFeedNameContext(); const activeFeedName = feedName ?? SharedFeedPage.MyFeed; - const { isAnyExplore, isExplorePopular, isExploreLatest } = useFeedName({ + const { isExplorePopular, isExploreLatest } = useFeedName({ feedName: activeFeedName, }); const router = useRouter(); @@ -276,7 +276,7 @@ export const FeedContainer = ({ 'flex items-center', isExtension && 'flex-1 flex-col-reverse', !isExtension && - 'w-full gap-2 border-b border-border-subtlest-tertiary px-3 py-1.5', + 'w-full gap-2 border-b border-border-subtlest-quaternary px-6 py-3', )} > {!!actionButtons && ( @@ -294,7 +294,7 @@ export const FeedContainer = ({ className={classNames( 'flex flex-col', !disableListFrame && - 'rounded-16 border border-border-subtlest-tertiary tablet:mt-6', + 'overflow-hidden rounded-16 border border-border-subtlest-tertiary tablet:mt-6', !disableListFrame && isSearch && 'mt-6', !disableListFrame && !isLaptop && '!mt-2 border-0', )} @@ -302,7 +302,7 @@ export const FeedContainer = ({ ( -
+
{feedHeading} @@ -324,8 +324,11 @@ export const FeedContainer = ({
+ ); return ( <> @@ -81,7 +86,7 @@ function MyFeedHeading({ size={feedSettingsButtonSize} variant={feedSettingsButtonVariant} className={feedSettingsButtonProps?.className} - icon={} + icon={feedSettingsButtonIcon} iconPosition={ButtonIconPosition.Right} > {feedSettingsButtonLabel} @@ -92,7 +97,7 @@ function MyFeedHeading({ size={feedSettingsButtonSize} variant={feedSettingsButtonVariant} className={feedSettingsButtonProps?.className} - icon={} + icon={feedSettingsButtonIcon} > {feedSettingsButtonLabel} diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index 047008e1b8..822c9628f2 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -102,11 +102,16 @@ export const SearchControlHeader = ({ return null; } + const compactIconButtonClassName = + '!size-8 !rounded-10 !border-transparent !bg-transparent !p-0 hover:!bg-surface-hover'; + const compactTextButtonClassName = + '!h-8 !rounded-10 !border-transparent !bg-transparent !px-3 hover:!bg-surface-hover'; + const dropdownProps: Partial = { className: { label: 'hidden', chevron: 'hidden', - button: '!border-transparent !bg-transparent !px-1 hover:!bg-surface-hover', + button: compactIconButtonClassName, container: 'flex', }, shouldIndicateSelected: true, @@ -164,8 +169,8 @@ export const SearchControlHeader = ({ ? { size: ButtonSize.Small, variant: ButtonVariant.Tertiary, - className: - '!border-transparent !bg-transparent hover:!bg-surface-hover', + className: compactTextButtonClassName, + iconSize: IconSize.XSmall, } : undefined } @@ -175,7 +180,7 @@ export const SearchControlHeader = ({ } + icon={} selectedIndex={selectedPeriod} options={periodTexts} onChange={(_, index) => setSelectedPeriod(index)} @@ -185,7 +190,7 @@ export const SearchControlHeader = ({ } + icon={} selectedIndex={selectedAlgo} options={algorithmsList} onChange={(_, index) => setSelectedAlgo(index)} @@ -202,11 +207,11 @@ export const SearchControlHeader = ({ ? { size: ButtonSize.Small, variant: ButtonVariant.Tertiary, - className: - '!border-transparent !bg-transparent hover:!bg-surface-hover', + className: compactIconButtonClassName, } : undefined } + iconSize={IconSize.XSmall} key="toggle-clickbait-shield" /> ), @@ -237,10 +242,7 @@ export const SearchControlHeader = ({ }} >
{actions}
{sideActions.length > 0 && ( diff --git a/packages/shared/src/components/utilities/common.tsx b/packages/shared/src/components/utilities/common.tsx index 804b7e1753..fa7be11f57 100644 --- a/packages/shared/src/components/utilities/common.tsx +++ b/packages/shared/src/components/utilities/common.tsx @@ -4,7 +4,6 @@ import classNames from 'classnames'; import classed from '../../lib/classed'; import styles from './utilities.module.css'; import { ArrowIcon } from '../icons'; -import { pageMainClassNames } from '../layout/PageWrapperLayout'; import { SourceMemberRole } from '../../graphql/sources'; import type { OrganizationMemberRole } from '../../features/organizations/types'; @@ -98,11 +97,7 @@ export const BaseFeedPage = classed( styles.feedPage, ); -export const FeedPage = classed( - BaseFeedPage, - pageMainClassNames, - styles.feedPage, -); +export const FeedPage = classed(BaseFeedPage, styles.feedPage); export const FeedPageLayoutList = classed( BasePageContainer, pageContainerClassNames, From f8a60988b1cdfc8b108b52546370caa473f764a3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 15:29:08 +0300 Subject: [PATCH 20/34] fix(sidebar): resolve CI lint, build, and test failures - Add explicit href to patterns to satisfy jsx-a11y - Extract sidebar category resolution + ProfileButton triggers to helpers, removing nested ternaries and stale exhaustive-deps - Default sidebarRecentExpanded so SettingsFlags type stays satisfied - Apply prettier/tailwind/type-imports auto-fixes to touched files - Update tests for dual-sidebar layout (Discover panel, settings link removed from ProfileMenu, MainSection Game Center moved out) Co-authored-by: Cursor --- .../modals/AchievementPickerModal.tsx | 10 +- .../notifications/NotificationsBell.tsx | 4 +- .../components/profile/ProfileButton.spec.tsx | 16 -- .../src/components/profile/ProfileButton.tsx | 156 ++++++++++-------- .../profile/ProfileSettingsMenu.tsx | 6 +- .../src/components/quest/QuestButton.tsx | 4 +- .../shared/src/components/sidebar/Section.tsx | 2 +- .../src/components/sidebar/Sidebar.spec.tsx | 8 +- .../src/components/sidebar/SidebarDesktop.tsx | 60 +++---- .../sidebar/sections/MainSection.spec.tsx | 117 ------------- .../sidebar/sections/MainSection.tsx | 1 - .../components/streak/ReadingStreakButton.tsx | 3 +- .../shared/src/contexts/SettingsContext.tsx | 1 + packages/shared/src/graphql/settings.ts | 3 +- packages/webapp/__tests__/MainLayout.tsx | 9 +- .../layouts/SettingsLayout/index.tsx | 26 ++- 16 files changed, 160 insertions(+), 266 deletions(-) delete mode 100644 packages/shared/src/components/sidebar/sections/MainSection.spec.tsx diff --git a/packages/shared/src/components/modals/AchievementPickerModal.tsx b/packages/shared/src/components/modals/AchievementPickerModal.tsx index 7f1916ba59..2d1e6e2bd2 100644 --- a/packages/shared/src/components/modals/AchievementPickerModal.tsx +++ b/packages/shared/src/components/modals/AchievementPickerModal.tsx @@ -82,12 +82,18 @@ export const AchievementPickerContent = ({ Choose an achievement to track - + Pick one to focus on next. {lockedAchievements.length === 0 && ( - + You unlocked every achievement. )} diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 4939ec1f9b..9afc5d799e 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -44,10 +44,12 @@ function NotificationsBell({
diff --git a/packages/shared/src/components/profile/ProfileButton.spec.tsx b/packages/shared/src/components/profile/ProfileButton.spec.tsx index 35571e1682..83cb4f8bee 100644 --- a/packages/shared/src/components/profile/ProfileButton.spec.tsx +++ b/packages/shared/src/components/profile/ProfileButton.spec.tsx @@ -60,22 +60,6 @@ it('should show "Reputation" tooltip on the reputation badge', () => { expect(screen.getByLabelText('Reputation')).toBeInTheDocument(); }); -it('should show settings option that opens modal', async () => { - renderComponent(); - - const profileBtn = await screen.findByRole('button', { - name: 'Profile settings', - }); - await act(async () => { - profileBtn.click(); - }); - - const settingsButton = await screen.findByRole('link', { - name: 'Settings', - }); - expect(settingsButton).toBeInTheDocument(); -}); - it('should click the logout button and logout', async () => { renderComponent(); diff --git a/packages/shared/src/components/profile/ProfileButton.tsx b/packages/shared/src/components/profile/ProfileButton.tsx index 68dc1bbc13..cbb2ceca33 100644 --- a/packages/shared/src/components/profile/ProfileButton.tsx +++ b/packages/shared/src/components/profile/ProfileButton.tsx @@ -203,15 +203,19 @@ export default function ProfileButton({ return <>; } - return ( - <> - {settingsIconOnly ? ( + const renderTrigger = (): ReactElement => { + if (settingsIconOnly) { + return ( - ) : compact ? ( + ); + } + + if (compact) { + return ( - ) : ( -
- {isStreaksEnabled && streak && ( - - )} - {hasCoresAccess && ( - - Wallet -
- {preciseBalance} Cores - - } + ); + } + + return ( +
+ {isStreaksEnabled && streak && ( + + )} + {hasCoresAccess && ( + + Wallet +
+ {preciseBalance} Cores + + } + > +
-
- - - -
- + + + +
+
+ )} + -
- )} + + + +
+ +
+
+ +
+ ); + }; + + return ( + <> + {renderTrigger()} {isOpen && ( onUpdate(false)} diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index 841be6b1d1..f51065238b 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -30,11 +30,7 @@ import { JoystickIcon, } from '../icons'; import { NavDrawer } from '../drawers/NavDrawer'; -import { - settingsUrl, - walletUrl, - webappUrl, -} from '../../lib/constants'; +import { settingsUrl, walletUrl, webappUrl } from '../../lib/constants'; import type { ProfileSectionItemProps, diff --git a/packages/shared/src/components/quest/QuestButton.tsx b/packages/shared/src/components/quest/QuestButton.tsx index 1c084657f6..8f4e94be7f 100644 --- a/packages/shared/src/components/quest/QuestButton.tsx +++ b/packages/shared/src/components/quest/QuestButton.tsx @@ -1781,7 +1781,9 @@ export const QuestButton = ({ claimingQuestId={claimingQuestId} animatingClaimRotationIds={animatingClaimRotationIdSet} claimedStampRotationIds={claimedStampRotationIdSet} - animatingClaimedStampRotationIds={animatingClaimedStampRotationIdSet} + animatingClaimedStampRotationIds={ + animatingClaimedStampRotationIdSet + } deferredClaimedStampRotationIds={deferredClaimedStampRotationIdSet} onClaim={handleClaim} onDestinationClick={handleDestinationClick} diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx index 03c3473aba..01a5a6dcd4 100644 --- a/packages/shared/src/components/sidebar/Section.tsx +++ b/packages/shared/src/components/sidebar/Section.tsx @@ -76,7 +76,7 @@ export function Section({ {/* Header content shown when sidebar is expanded */}
diff --git a/packages/shared/src/components/sidebar/Sidebar.spec.tsx b/packages/shared/src/components/sidebar/Sidebar.spec.tsx index e964e8058f..934bc55c49 100644 --- a/packages/shared/src/components/sidebar/Sidebar.spec.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.spec.tsx @@ -184,9 +184,7 @@ it('should require login before opening following for anonymous users', async () ); }); -const sidebarItems = [ - ['Explore', '/posts'], -]; +const sidebarItems = [['Explore', '/posts']]; const discoverItems = [ ['Discussions', '/discussed'], @@ -209,10 +207,12 @@ describe('sidebar items', () => { ); it.each(discoverItems.map((item) => [item[0], item[1]]))( - 'it should expect %s to exist in home', + 'it should expect %s to exist in the Discover panel', async (name, href) => { renderComponent(); waitForNock(); + const discoverTab = await screen.findByRole('tab', { name: 'Discover' }); + fireEvent.click(discoverTab); const el = await screen.findByText(name); expect(el).toBeInTheDocument(); // eslint-disable-next-line testing-library/no-node-access diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index 3833bc9f06..4cb2027b9e 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -264,38 +264,23 @@ export const SidebarDesktop = ({ const { openNewSquad } = useSquadNavigation(); const { isLoggedIn, user } = useAuthContext(); useRecentPagesTracker(); - const activePage = activePageProp || router.asPath || router.pathname; + const activePage = activePageProp || router.asPath || router.pathname || ''; const isUserProfileActive = !!user?.username && activePage.includes(`/${user.username}`); const isFeedPage = activePage.includes('/feeds/'); - const [selectedCategory, setSelectedCategory] = useState( - isUserProfileActive - ? SidebarSelectedCategory.Profile - : isFeedPage - ? SidebarSelectedCategory.Main - : normalizeSidebarCategory( - flags?.sidebarSelectedCategory ?? getSidebarCategoryForPath(activePage), - ), - ); - - useEffect(() => { - const activeCategory = getSidebarCategoryForPath(activePage); + const resolveCategory = useCallback((): SidebarSelectedCategory => { if (isFeedPage) { - setSelectedCategory(SidebarSelectedCategory.Main); - return; + return SidebarSelectedCategory.Main; } - if (isUserProfileActive) { - setSelectedCategory(SidebarSelectedCategory.Profile); - return; + return SidebarSelectedCategory.Profile; } - - setSelectedCategory( - activeCategory === SidebarSelectedCategory.Main - ? normalizeSidebarCategory(flags?.sidebarSelectedCategory) - : activeCategory, - ); + const activeCategory = getSidebarCategoryForPath(activePage); + if (activeCategory === SidebarSelectedCategory.Main) { + return normalizeSidebarCategory(flags?.sidebarSelectedCategory); + } + return activeCategory; }, [ activePage, flags?.sidebarSelectedCategory, @@ -303,6 +288,12 @@ export const SidebarDesktop = ({ isUserProfileActive, ]); + const [selectedCategory, setSelectedCategory] = useState(resolveCategory); + + useEffect(() => { + setSelectedCategory(resolveCategory()); + }, [resolveCategory]); + const defaultRenderSectionProps = useMemo( () => ({ sidebarExpanded: true, @@ -342,10 +333,7 @@ export const SidebarDesktop = ({ if (selectedCategory === SidebarSelectedCategory.Saved) { return ( - + ); } @@ -442,8 +430,9 @@ export const SidebarDesktop = ({
@@ -488,7 +477,10 @@ export const SidebarDesktop = ({ className="flex flex-col items-center gap-1" > {sidebarCategories.map((category) => { - if (category.id === SidebarSelectedCategory.Profile && !isLoggedIn) { + if ( + category.id === SidebarSelectedCategory.Profile && + !isLoggedIn + ) { return null; } @@ -525,12 +517,12 @@ export const SidebarDesktop = ({
onSelectCategory(SidebarSelectedCategory.Settings) @@ -556,7 +548,7 @@ export const SidebarDesktop = ({ type="button" onClick={onToggleExpanded} aria-label="Open sidebar" - className="focus-outline absolute left-16 top-4 z-1 hidden size-6 -translate-x-1/2 items-center justify-center rounded-full border border-border-subtlest-tertiary bg-background-default text-text-tertiary shadow-1 transition-colors hover:bg-surface-hover hover:text-text-primary laptop:flex" + className="focus-outline shadow-1 absolute left-16 top-4 z-1 hidden size-6 -translate-x-1/2 items-center justify-center rounded-full border border-border-subtlest-tertiary bg-background-default text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary laptop:flex" > diff --git a/packages/shared/src/components/sidebar/sections/MainSection.spec.tsx b/packages/shared/src/components/sidebar/sections/MainSection.spec.tsx deleted file mode 100644 index cbd60d1550..0000000000 --- a/packages/shared/src/components/sidebar/sections/MainSection.spec.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { - gameCenterMilestoneSectionId, - webappUrl, -} from '../../../lib/constants'; -import { useConditionalFeature } from '../../../hooks'; -import useCustomDefaultFeed from '../../../hooks/feed/useCustomDefaultFeed'; -import { useQuestDashboard } from '../../../hooks/useQuestDashboard'; -import { MainSection } from './MainSection'; - -jest.mock('../Section', () => ({ - Section: ({ items }: { items: { title: string; path?: string }[] }) => ( - - ), -})); - -jest.mock('../../../contexts/AuthContext', () => { - const actual = jest.requireActual('../../../contexts/AuthContext'); - - return { - ...actual, - useAuthContext: jest.fn(), - }; -}); - -jest.mock('../../../hooks', () => { - const actual = jest.requireActual('../../../hooks'); - - return { - ...actual, - useConditionalFeature: jest.fn(), - }; -}); - -jest.mock('../../../hooks/feed/useCustomDefaultFeed', () => ({ - __esModule: true, - default: jest.fn(), -})); - -jest.mock('../../../hooks/useQuestDashboard', () => ({ - useQuestDashboard: jest.fn(), -})); - -const mockUseAuthContext = useAuthContext as jest.Mock; -const mockUseConditionalFeature = useConditionalFeature as jest.Mock; -const mockUseCustomDefaultFeed = useCustomDefaultFeed as jest.Mock; -const mockUseQuestDashboard = useQuestDashboard as jest.Mock; - -describe('MainSection', () => { - beforeEach(() => { - mockUseAuthContext.mockReturnValue({ - user: { - id: 'user-1', - isPlus: false, - }, - isLoggedIn: true, - }); - mockUseConditionalFeature.mockReturnValue({ - value: false, - isLoading: false, - }); - mockUseCustomDefaultFeed.mockReturnValue({ - isCustomDefaultFeed: false, - }); - mockUseQuestDashboard.mockReturnValue({ - data: { - milestone: [], - }, - }); - }); - - it('should keep the game center link on the main page when no milestone quests are claimable', () => { - render( - , - ); - - expect(screen.getByRole('link', { name: 'Game Center' })).toHaveAttribute( - 'href', - `${webappUrl}game-center`, - ); - }); - - it('should link directly to milestone quests when claimable milestone quests exist', () => { - mockUseQuestDashboard.mockReturnValue({ - data: { - milestone: [{ claimable: true }, { claimable: false }], - }, - }); - - render( - , - ); - - expect(screen.getByRole('link', { name: 'Game Center' })).toHaveAttribute( - 'href', - `${webappUrl}game-center#${gameCenterMilestoneSectionId}`, - ); - }); -}); diff --git a/packages/shared/src/components/sidebar/sections/MainSection.tsx b/packages/shared/src/components/sidebar/sections/MainSection.tsx index 1b77552bea..939015a4db 100644 --- a/packages/shared/src/components/sidebar/sections/MainSection.tsx +++ b/packages/shared/src/components/sidebar/sections/MainSection.tsx @@ -155,7 +155,6 @@ export const MainSection = ({ isPlus, onNavTabClick, showYearInReview, - user, ]); return ( diff --git a/packages/shared/src/components/streak/ReadingStreakButton.tsx b/packages/shared/src/components/streak/ReadingStreakButton.tsx index d6d7856cd0..bc51cbd7ae 100644 --- a/packages/shared/src/components/streak/ReadingStreakButton.tsx +++ b/packages/shared/src/components/streak/ReadingStreakButton.tsx @@ -21,7 +21,8 @@ import ConditionalWrapper from '../ConditionalWrapper'; import type { TooltipPosition } from '../tooltips/BaseTooltipContainer'; import { useAuthContext } from '../../contexts/AuthContext'; import { isSameDayInTimezone } from '../../lib/timezones'; -import { IconSize, IconWrapper } from '../Icon'; +import type { IconSize } from '../Icon'; +import { IconWrapper } from '../Icon'; import { useStreakTimezoneOk } from '../../hooks/streaks/useStreakTimezoneOk'; interface ReadingStreakButtonProps { diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index 4281dd5bb2..554e31d796 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -207,6 +207,7 @@ const defaultSettings: RemoteSettings = { sidebarOtherExpanded: true, sidebarResourcesExpanded: true, sidebarBookmarksExpanded: true, + sidebarRecentExpanded: true, sidebarSelectedCategory: SidebarSelectedCategory.Main, clickbaitShieldEnabled: true, defaultWriteTab: WriteFormTab.NewPost, diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 27aa3f6fdb..8aeba34d40 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -81,7 +81,8 @@ export const CLIENT_ONLY_SETTINGS_FLAGS = [ 'sidebarRecentExpanded', ] as const satisfies ReadonlyArray; -export type ClientOnlySettingsFlag = (typeof CLIENT_ONLY_SETTINGS_FLAGS)[number]; +export type ClientOnlySettingsFlag = + (typeof CLIENT_ONLY_SETTINGS_FLAGS)[number]; export enum SidebarSelectedCategory { Main = 'main', diff --git a/packages/webapp/__tests__/MainLayout.tsx b/packages/webapp/__tests__/MainLayout.tsx index d2f4b8406e..334fb1efe6 100644 --- a/packages/webapp/__tests__/MainLayout.tsx +++ b/packages/webapp/__tests__/MainLayout.tsx @@ -15,11 +15,14 @@ describe('MainLayout', () => { jest.spyOn(hooks, 'useViewSize').mockImplementation(() => true); }); - const renderLayout = (user: LoggedUser = null): RenderResult => { + const renderLayout = (user: LoggedUser | null = null): RenderResult => { const client = new QueryClient(); return render( - + , ); @@ -44,9 +47,9 @@ describe('MainLayout', () => { reputation: 5, permalink: 'https://app.daily.dev/ido', infoConfirmed: true, + balance: { amount: 0 }, }); const [el] = await screen.findAllByAltText(`idoshamun's profile`); expect(el).toHaveAttribute('src', 'https://daily.dev/ido.png'); - expect(screen.getByText('5')).toBeInTheDocument(); }); }); diff --git a/packages/webapp/components/layouts/SettingsLayout/index.tsx b/packages/webapp/components/layouts/SettingsLayout/index.tsx index c46481a965..7abdf0b734 100644 --- a/packages/webapp/components/layouts/SettingsLayout/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/index.tsx @@ -84,6 +84,22 @@ export default function SettingsLayout({ const { formRef } = useAuthForms(); + const renderSettingsMenu = (): ReactElement | null => { + if (isMobile) { + return ( + router.push(profile?.permalink ?? webappUrl)} + /> + ); + } + if (isLaptop) { + return null; + } + return ; + }; + if (!isAuthReady) { return null; } @@ -151,15 +167,7 @@ export default function SettingsLayout({ )}

Settings

- {isMobile ? ( - router.push(profile.permalink)} - /> - ) : isLaptop ? null : ( - - )} + {renderSettingsMenu()} {children}
From 28c4a6985fd4f5620b81a351e7e83f5e4fc0f9a1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 15:39:20 +0300 Subject: [PATCH 21/34] chore: re-trigger CI Co-authored-by: Cursor From 404228b421116c4639fec1a6643695b3cd258108 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 17:55:07 +0300 Subject: [PATCH 22/34] feat(sidebar): polish dual-sidebar UX - Address PR review feedback: - Remove unused client-only flags from defaultSettings; mark sidebarRecentExpanded optional so localStorage hydration works - Tighten useRecentPages: drop /[userId] auto-detector (avoids recording 404s and reserved top-level slugs); record explicitly from ProfileLayout once the profile resolves; clear store on logout so recents don't leak between users - Fix Jobs sidebar URL trailing slash when no opportunityId - Drop Achievements/DevCard/Core wallet/Game Center duplicates from the settings menu (they live in the Profile rail panel) - Wire bottom Settings rail link with role=tab/aria-controls/ aria-selected and group it in its own utility tablist - Skip persisting Settings/GameCenter selection (they're navigations, not panel state) - Replace formRef! non-null assertion with explicit guard - Add aria-hidden to the decorative app-icon image - Replace InnerProfileSettingsMenu in the Settings rail panel with a new SettingsPanelSection that uses sidebar-native Section + SidebarItem styling, so settings options match Home/Discover/ Saved/Squads/Profile row size, padding, icon size, and typography - Restore the dual-sidebar floating-card treatment unconditionally for logged-in users: revert the screenCentered gate that was killing the feed card border/background, adding a 64px top gap, and showing a duplicate global FeedbackWidget on most pages - Add Slack-style floating hover preview anchored to each rail icon. Uses @radix-ui/react-hover-card for the safe-polygon hover bridge; opens in 250ms, closes in 120ms; suppressed when the category is already pinned and the sidebar is expanded; styled with bg-surface-float + shadow-3 + stronger border to lift visibly off the rail; max-height capped to viewport with an internal scroll area so long lists stay reachable Co-authored-by: Cursor --- packages/shared/src/components/MainLayout.tsx | 3 +- .../profile/ProfileSettingsMenu.tsx | 72 ++-- .../src/components/sidebar/RailHoverPanel.tsx | 43 +++ .../src/components/sidebar/SidebarDesktop.tsx | 160 +++++++-- .../sidebar/sections/ProfileSection.tsx | 4 +- .../sidebar/sections/SettingsPanelSection.tsx | 316 ++++++++++++++++++ packages/shared/src/contexts/AuthContext.tsx | 4 + .../shared/src/contexts/SettingsContext.tsx | 7 +- packages/shared/src/graphql/settings.ts | 5 +- .../shared/src/hooks/feed/useRecentPages.ts | 47 ++- .../layouts/ProfileLayout/index.tsx | 28 +- .../layouts/SettingsLayout/index.tsx | 5 +- 12 files changed, 581 insertions(+), 113 deletions(-) create mode 100644 packages/shared/src/components/sidebar/RailHoverPanel.tsx create mode 100644 packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 236e7ed604..e8bbabb1d1 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -93,7 +93,8 @@ function MainLayoutComponent({ // The dual-sidebar layout takes ownership of the global header chrome // (logo + search + user actions) for authenticated users on laptop+. // When that's the case we hide the global header and switch the main - // content over to the floating card treatment. + // content over to the floating card treatment (rounded, bordered, shadow) + // and hide the global feedback widget (the rail provides its own). const sidebarOwnsHeader = isLoggedIn && showSidebar && sidebarRendered; const [hasLoggedImpression, setHasLoggedImpression] = useState(false); const { feedName } = useActiveFeedNameContext(); diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index f51065238b..668e98eb3f 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -6,12 +6,10 @@ import { AddUserIcon, BellIcon, EditIcon, - DevCardIcon, EmbedIcon, AppIcon, UserIcon, BlockIcon, - CoinIcon, CreditCardIcon, HashtagIcon, HotIcon, @@ -27,10 +25,9 @@ import { TerminalIcon, TourIcon, FeatherIcon, - JoystickIcon, } from '../icons'; import { NavDrawer } from '../drawers/NavDrawer'; -import { settingsUrl, walletUrl, webappUrl } from '../../lib/constants'; +import { settingsUrl, webappUrl } from '../../lib/constants'; import type { ProfileSectionItemProps, @@ -47,14 +44,12 @@ import { ThemeSection } from '../ProfileMenu/sections/ThemeSection'; import { ProfileImageSize } from '../ProfilePicture'; import { useViewSize, ViewSize } from '../../hooks'; import { TypographyColor, TypographyType } from '../typography/Typography'; -import { useHasAccessToCores } from '../../hooks/useCoresFeature'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent, TargetId } from '../../lib/log'; import { VolunteeringIcon } from '../icons/Volunteering'; import { GraduationIcon } from '../icons/Graduation'; -import { MedalBadgeIcon } from '../icons/MedalBadge'; import { MedalIcon } from '../icons/Medal'; type MenuItems = Record< @@ -70,7 +65,6 @@ const defineMenuItems = (items: T): T => items; const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { const { openModal } = useLazyModal(); const { logEvent } = useLogContext(); - const { user } = useAuthContext(); const items = useMemo( () => @@ -194,12 +188,9 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { playground: { title: 'Gamification', items: { - gameCenter: { - title: 'Game Center', - icon: JoystickIcon, - href: `${webappUrl}game-center`, - external: true, - }, + // Game Center, Achievements, and DevCard are intentionally + // omitted here — they live in the sidebar Profile rail. The + // settings menu is for settings, not duplicate dashboard links. gamification: { title: 'Feature visibility', icon: EyeIcon, @@ -210,12 +201,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { icon: HotIcon, href: `${settingsUrl}/customization/streaks`, }, - achievements: { - title: 'Achievements', - icon: MedalBadgeIcon, - href: `${webappUrl}${user?.username}/achievements`, - external: true, - }, hotTakes: { title: 'Hot Takes', icon: HotIcon, @@ -226,11 +211,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { onClose?.(); }, }, - devcard: { - title: 'DevCard', - icon: DevCardIcon, - href: `${settingsUrl}/customization/devcard`, - }, }, }, customization: { @@ -261,12 +241,9 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { icon: OrganizationIcon, href: `${settingsUrl}/organization`, }, - coreWallet: { - title: 'Core Wallet', - icon: CoinIcon, - href: walletUrl, - external: true, - }, + // Core wallet lives in the sidebar Profile rail (when the user + // has access). Removed from the settings menu to avoid duplicate + // destinations. adsDashboard: { title: 'Ads dashboard', icon: TrendingIcon, @@ -285,7 +262,7 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { }, }, }), - [logEvent, onClose, openModal, user?.username], + [logEvent, onClose, openModal], ); return { items }; @@ -303,7 +280,6 @@ export const InnerProfileSettingsMenu = ({ }: WithClassNameProps & { onClose?: () => void }) => { const { asPath } = useRouter(); const isMobile = useViewSize(ViewSize.MobileL); - const hasAccessToCores = useHasAccessToCores(); const { items: accountPageItems } = useAccountPageItems({ onClose }); return ( @@ -318,26 +294,18 @@ export const InnerProfileSettingsMenu = ({ key={key} withSeparator={!lastItem} title={menuItem.title ?? undefined} - items={Object.entries(menuItem.items) - .filter(([, item]) => { - if (item.href === walletUrl && !hasAccessToCores) { - return false; - } - - return true; - }) - .map(([, item]: [string, ProfileSectionItemProps]) => { - return { - ...item, - isActive: asPath === item.href, - ...(isMobile && { - typography: { - type: TypographyType.Body, - color: TypographyColor.Secondary, - }, - }), - }; - })} + items={Object.entries(menuItem.items).map( + ([, item]: [string, ProfileSectionItemProps]) => ({ + ...item, + isActive: asPath === item.href, + ...(isMobile && { + typography: { + type: TypographyType.Body, + color: TypographyColor.Secondary, + }, + }), + }), + )} /> ); })} diff --git a/packages/shared/src/components/sidebar/RailHoverPanel.tsx b/packages/shared/src/components/sidebar/RailHoverPanel.tsx new file mode 100644 index 0000000000..aef43d249a --- /dev/null +++ b/packages/shared/src/components/sidebar/RailHoverPanel.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Typography, TypographyType } from '../typography/Typography'; +import { Nav, SidebarScrollWrapper } from './common'; + +export interface RailHoverPanelProps { + title: string; + children: ReactNode; + className?: string; +} + +// Floating preview of a sidebar category, shown on rail hover. +// Mirrors the dedicated panel structure (title header + scrollable +// section list) but rendered as a portaled card so the user can peek at +// any category's content without changing the pinned selection. +// +// Styling: uses `bg-surface-float` (an elevated surface that's lighter +// than the rail backdrop in dark mode and darker in light mode), a +// stronger `shadow-3`, and a higher-contrast border so the popover lifts +// clearly off the sidebar instead of melting into it. The card is +// capped to the viewport height with an internal scroll area. +export const RailHoverPanel = ({ + title, + children, + className, +}: RailHoverPanelProps) => ( +
+
+ + {title} + +
+ + + +
+); diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index 4cb2027b9e..eca5ad910e 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import type { ReactElement, ReactNode } 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 { useSettingsContext } from '../../contexts/SettingsContext'; import { useLogContext } from '../../contexts/LogContext'; @@ -11,6 +12,7 @@ import { CustomFeedSection } from './sections/CustomFeedSection'; import { DiscoverSection } from './sections/DiscoverSection'; import { RecentSection } from './sections/RecentSection'; import { ProfileSection } from './sections/ProfileSection'; +import { SettingsPanelSection } from './sections/SettingsPanelSection'; import { CreatePostButton } from '../post/write'; import { ButtonIconPosition, ButtonSize } from '../buttons/Button'; import { BookmarkSection } from './sections/BookmarkSection'; @@ -38,6 +40,7 @@ import { SidebarSettingsFlags, } from '../../graphql/settings'; import { Tooltip } from '../tooltip/Tooltip'; +import { RailHoverPanel } from './RailHoverPanel'; import { useSpotlight } from '../spotlight/useSpotlight'; import { useAuthContext } from '../../contexts/AuthContext'; import NotificationsBell from '../notifications/NotificationsBell'; @@ -56,7 +59,6 @@ import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; import { ResourceSection } from '../ProfileMenu/sections/ResourceSection'; import { ProfileMenuFooter } from '../ProfileMenu/ProfileMenuFooter'; import { HorizontalSeparator } from '../utilities'; -import { InnerProfileSettingsMenu } from '../profile/ProfileSettingsMenu'; import { QuestButton } from '../quest/QuestButton'; import { AchievementTrackerPanel } from '../filters/AchievementTrackerButton'; import { Typography, TypographyType } from '../typography/Typography'; @@ -179,6 +181,56 @@ 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']; +const RAIL_HOVER_OPEN_DELAY = 250; +const RAIL_HOVER_CLOSE_DELAY = 120; +const RAIL_HOVER_SIDE_OFFSET = 12; + +interface RailHoverCardProps { + label: string; + children: ReactNode; + panel: ReactElement; + // When `false`, the trigger renders without a popover (used to avoid + // duplicating the currently-pinned panel that's already visible). + enabled?: boolean; +} + +// Slack-style floating preview anchored to a rail icon. The popover +// shows the same content as the dedicated panel but is rendered in a +// portal so it overlays whatever is currently pinned. Radix HoverCard +// gives us the safe-polygon hover bridge for free, so the cursor can +// travel from the rail into the popover without dismissing it. +const RailHoverCard = ({ + label, + children, + panel, + enabled = true, +}: RailHoverCardProps) => { + if (!enabled) { + return <>{children}; + } + return ( + + + {children} + + + + {panel} + + + + ); +}; + const SidebarSupportButton = (): ReactElement => { const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); @@ -306,7 +358,17 @@ export const SidebarDesktop = ({ const onSelectCategory = useCallback( (category: SidebarSelectedCategory) => { setSelectedCategory(category); - updateFlag(SidebarSettingsFlags.SelectedCategory, category); + // GameCenter and Settings are navigations, not persistent panel + // states: GameCenter routes to /game-center, Settings is rendered + // wherever the user lands under /settings. Persisting them to the + // server flag is wasted writes and noise — the value would be + // normalised back to Main on the next read anyway. + if ( + category !== SidebarSelectedCategory.GameCenter && + category !== SidebarSelectedCategory.Settings + ) { + updateFlag(SidebarSettingsFlags.SelectedCategory, category); + } if (category === SidebarSelectedCategory.GameCenter) { router.push(`${webappUrl}game-center`).catch(() => undefined); } @@ -321,8 +383,10 @@ export const SidebarDesktop = ({ toggleSidebarExpanded(); }, [logEvent, sidebarExpanded, toggleSidebarExpanded]); - const renderSelectedSection = (): ReactElement => { - if (selectedCategory === SidebarSelectedCategory.Squads) { + const renderCategorySection = ( + category: SidebarSelectedCategory, + ): ReactElement => { + if (category === SidebarSelectedCategory.Squads) { return ( ); } - if (selectedCategory === SidebarSelectedCategory.Discover) { + if (category === SidebarSelectedCategory.Discover) { return ( ; + if (category === SidebarSelectedCategory.Settings) { + return ( + + ); } - if (selectedCategory === SidebarSelectedCategory.GameCenter) { + if (category === SidebarSelectedCategory.GameCenter) { return (
@@ -359,7 +428,7 @@ export const SidebarDesktop = ({ ); } - if (selectedCategory === SidebarSelectedCategory.Profile) { + if (category === SidebarSelectedCategory.Profile) { return ( ); @@ -387,6 +456,9 @@ export const SidebarDesktop = ({ ); }; + const renderSelectedSection = (): ReactElement => + renderCategorySection(selectedCategory); + const selectedLabel = sidebarCategories.find( (category) => category.id === selectedCategory, )?.label; @@ -438,6 +510,7 @@ export const SidebarDesktop = ({ @@ -490,7 +563,11 @@ export const SidebarDesktop = ({ {category.id === SidebarSelectedCategory.Saved && isLoggedIn && } - + - + ); })}
-
- - - + diff --git a/packages/shared/src/components/sidebar/sections/ProfileSection.tsx b/packages/shared/src/components/sidebar/sections/ProfileSection.tsx index d7e78b3417..762892737c 100644 --- a/packages/shared/src/components/sidebar/sections/ProfileSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfileSection.tsx @@ -42,7 +42,9 @@ export const ProfileSection = ({ }, { title: 'Jobs', - path: `${webappUrl}jobs/${alerts.opportunityId ?? ''}`, + path: `${webappUrl}jobs${ + alerts.opportunityId ? `/${alerts.opportunityId}` : '' + }`, action: logOpportunityNudgeClick, icon: (active: boolean) => ( } /> diff --git a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx new file mode 100644 index 0000000000..80c8161fe2 --- /dev/null +++ b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx @@ -0,0 +1,316 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { + AddUserIcon, + AppIcon, + BellIcon, + BlockIcon, + CreditCardIcon, + EditIcon, + EmbedIcon, + ExitIcon, + EyeIcon, + FeatherIcon, + HashtagIcon, + HotIcon, + InviteIcon, + JobIcon, + MagicIcon, + MailIcon, + NewTabIcon, + OrganizationIcon, + TerminalIcon, + TourIcon, + TrendingIcon, + UserIcon, +} from '../../icons'; +import { GraduationIcon } from '../../icons/Graduation'; +import { MedalIcon } from '../../icons/Medal'; +import { VolunteeringIcon } from '../../icons/Volunteering'; +import type { SidebarMenuItem } from '../common'; +import { ListIcon } from '../common'; +import { Section } from '../Section'; +import type { SidebarSectionProps } from './common'; +import { settingsUrl, webappUrl } 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'; +import { LogEvent, TargetId } from '../../../lib/log'; + +type SettingsGroup = { + key: string; + title?: string; + items: SidebarMenuItem[]; +}; + +export const SettingsPanelSection = ({ + isItemsButton, + ...defaultRenderSectionProps +}: SidebarSectionProps): ReactElement => { + const { openModal } = useLazyModal(); + const { logEvent } = useLogContext(); + + const groups: SettingsGroup[] = useMemo( + () => [ + { + key: 'main', + items: [ + { + title: 'Profile details', + path: `${settingsUrl}/profile`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Account & Security', + path: `${settingsUrl}/security`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Notifications', + path: `${settingsUrl}/notifications`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Job preferences', + path: `${settingsUrl}/job-preferences`, + icon: (active: boolean) => ( + } /> + ), + action: () => + logEvent({ + event_name: LogEvent.ClickCandidatePreferences, + target_id: TargetId.ProfileSettingsMenu, + }), + }, + { + title: 'Appearance', + path: `${settingsUrl}/appearance`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Posting', + path: `${settingsUrl}/composition`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Invite Friends', + path: `${settingsUrl}/invite`, + icon: (active: boolean) => ( + } /> + ), + }, + ], + }, + { + key: 'feed', + title: 'Feed settings', + items: [ + { + title: 'General', + path: `${settingsUrl}/feed/general`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Tags', + path: `${settingsUrl}/feed/tags`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Content sources', + path: `${settingsUrl}/feed/sources`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Content preferences', + path: `${settingsUrl}/feed/preferences`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'AI superpowers', + path: `${settingsUrl}/feed/ai`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Blocked content', + path: `${settingsUrl}/feed/blocked`, + icon: (active: boolean) => ( + } /> + ), + }, + ], + }, + { + key: 'career', + title: 'Career', + items: [ + { + title: 'Work Experience', + path: `${settingsUrl}/profile/experience/work`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Education', + path: `${settingsUrl}/profile/experience/education`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Certifications', + path: `${settingsUrl}/profile/experience/certification`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Open Source', + path: `${settingsUrl}/profile/experience/opensource`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Projects & Publications', + path: `${settingsUrl}/profile/experience/project`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Volunteering', + path: `${settingsUrl}/profile/experience/volunteering`, + icon: (active: boolean) => ( + } /> + ), + }, + ], + }, + { + key: 'gamification', + title: 'Gamification', + items: [ + { + title: 'Feature visibility', + path: `${settingsUrl}/customization/gamification`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Streaks', + path: `${settingsUrl}/customization/streaks`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Hot Takes', + path: `${webappUrl}?openModal=hottakes`, + icon: (active: boolean) => ( + } /> + ), + action: () => logEvent({ event_name: LogEvent.OpenHotAndCold }), + }, + ], + }, + { + key: 'developers', + title: 'Developers', + items: [ + { + title: 'API Access', + path: `${settingsUrl}/api`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Integrations', + path: `${settingsUrl}/customization/integrations`, + icon: (active: boolean) => ( + } /> + ), + }, + ], + }, + { + key: 'billing', + title: 'Billing and Monetization', + items: [ + { + title: 'Subscriptions', + path: `${settingsUrl}/subscription`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Organizations', + path: `${settingsUrl}/organization`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Ads dashboard', + icon: (active: boolean) => ( + } /> + ), + action: () => openModal({ type: LazyModal.AdsDashboard }), + }, + ], + }, + { + key: 'logout', + items: [ + { + title: 'Log out', + icon: (active: boolean) => ( + } /> + ), + action: () => logout(LogoutReason.ManualLogout), + }, + ], + }, + ], + [logEvent, openModal], + ); + + return ( + <> + {groups.map((group) => ( +
+ ))} + + ); +}; diff --git a/packages/shared/src/contexts/AuthContext.tsx b/packages/shared/src/contexts/AuthContext.tsx index 3cb45a3143..d9209380e3 100644 --- a/packages/shared/src/contexts/AuthContext.tsx +++ b/packages/shared/src/contexts/AuthContext.tsx @@ -10,6 +10,7 @@ import type { QueryObserverResult } from '@tanstack/react-query'; import { useRouter } from 'next/router'; import type { AnonymousUser, LoggedUser } from '../lib/user'; import { deleteAccount, logout as dispatchLogout } from '../lib/user'; +import { clearRecentPages } from '../hooks/feed/useRecentPages'; import type { AccessToken, Boot, Visit } from '../lib/boot'; import { isCompanionActivated } from '../lib/element'; import type { AuthTriggersType } from '../lib/auth'; @@ -92,6 +93,9 @@ export const REGISTRATION_PATH = '/register'; export const logout = async (reason: string): Promise => { await dispatchLogout(reason); + // Recent pages are stored per-browser, not per-user. Clear them on logout + // so the next user doesn't see the previous user's squads/tags/profiles. + clearRecentPages(); const params = getQueryParams(); if (params.redirect_uri) { window.location.replace(params.redirect_uri); diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index 554e31d796..7a687ddd18 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -17,7 +17,6 @@ import type { import { CampaignCtaPlacement, CLIENT_ONLY_SETTINGS_FLAGS, - SidebarSelectedCategory, UPDATE_USER_SETTINGS_MUTATION, } from '../graphql/settings'; import { WriteFormTab } from '../components/fields/form/common'; @@ -207,8 +206,10 @@ const defaultSettings: RemoteSettings = { sidebarOtherExpanded: true, sidebarResourcesExpanded: true, sidebarBookmarksExpanded: true, - sidebarRecentExpanded: true, - sidebarSelectedCategory: SidebarSelectedCategory.Main, + // Client-only flags (sidebarRecentExpanded, sidebarSelectedCategory) + // are intentionally omitted — they live in localStorage and the + // hydration effect below restores them only when they are absent + // from the server settings payload. clickbaitShieldEnabled: true, defaultWriteTab: WriteFormTab.NewPost, }, diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 8aeba34d40..578ca75ef2 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -43,7 +43,10 @@ export type SettingsFlags = { sidebarOtherExpanded: boolean; sidebarResourcesExpanded: boolean; sidebarBookmarksExpanded: boolean; - sidebarRecentExpanded: boolean; + // Client-only flags — must stay optional so SettingsContext hydration + // can detect their absence (`flags?.[key] === undefined`) and overlay + // the persisted localStorage value without being shadowed by defaults. + sidebarRecentExpanded?: boolean; sidebarSelectedCategory?: SidebarSelectedCategory; clickbaitShieldEnabled: boolean; timezoneMismatchIgnore?: string; diff --git a/packages/shared/src/hooks/feed/useRecentPages.ts b/packages/shared/src/hooks/feed/useRecentPages.ts index 67da4c8dfa..8eaafd3633 100644 --- a/packages/shared/src/hooks/feed/useRecentPages.ts +++ b/packages/shared/src/hooks/feed/useRecentPages.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; -import { get as getCache, set as setCache } from 'idb-keyval'; +import { del as delCache, get as getCache, set as setCache } from 'idb-keyval'; export const RECENT_PAGES_LIMIT = 5; export const RECENT_PAGES_STORAGE_KEY = 'daily.recentPages'; @@ -61,13 +61,10 @@ const detectors: Record = { } return { path: canonicalize(asPath), title: slugOrId, kind: 'feed' }; }, - '/[userId]': (query, asPath) => { - const userId = firstValue(query.userId); - if (!userId) { - return null; - } - return { path: canonicalize(asPath), title: `@${userId}`, kind: 'user' }; - }, + // /[userId] is intentionally NOT detected here: the catch-all route also + // matches non-existent profiles (404s) and other top-level slugs. User + // visits are recorded explicitly from ProfileLayout once the profile has + // resolved successfully — see `useRecordRecentUserVisit`. }; const detectRecentPage = ( @@ -155,6 +152,15 @@ class RecentPagesStore { }; } + clear(): void { + if (this.pages.length === 0) { + return; + } + this.pages = []; + delCache(RECENT_PAGES_STORAGE_KEY).catch(() => undefined); + this.notify(); + } + private notify(): void { this.listeners.forEach((listener) => listener(this.pages)); } @@ -192,3 +198,28 @@ export const useRecentPages = (): RecentPage[] => { return pages; }; + +// Records a user-profile visit only after the profile has been resolved +// successfully, so we never write 404 paths or other top-level slugs that +// happen to match the /[userId] catch-all. +export const useRecordRecentUserVisit = ( + user: { username?: string | null } | null | undefined, +): void => { + const router = useRouter(); + + useEffect(() => { + if (!user?.username) { + return; + } + recentPagesStore.load(); + recentPagesStore.record({ + path: canonicalize(router.asPath), + title: `@${user.username}`, + kind: 'user', + }); + }, [router.asPath, user?.username]); +}; + +export const clearRecentPages = (): void => { + recentPagesStore.clear(); +}; diff --git a/packages/webapp/components/layouts/ProfileLayout/index.tsx b/packages/webapp/components/layouts/ProfileLayout/index.tsx index f3b3b3146c..fb2241ba99 100644 --- a/packages/webapp/components/layouts/ProfileLayout/index.tsx +++ b/packages/webapp/components/layouts/ProfileLayout/index.tsx @@ -19,6 +19,7 @@ import Head from 'next/head'; import type { NextSeoProps } from 'next-seo'; import { ClientQuestEventType } from '@dailydotdev/shared/src/graphql/quests'; import { useProfile } from '@dailydotdev/shared/src/hooks/profile/useProfile'; +import { useRecordRecentUserVisit } from '@dailydotdev/shared/src/hooks/feed/useRecentPages'; import { useTrackQuestClientEvent } from '@dailydotdev/shared/src/hooks/useTrackQuestClientEvent'; import CustomAuthBanner from '@dailydotdev/shared/src/components/auth/CustomAuthBanner'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; @@ -103,7 +104,7 @@ export default function ProfileLayout({ const { user: viewer } = useAuthContext(); const [trackedView, setTrackedView] = useState(false); const { logEvent } = useLogContext(); - const { referrerPost } = usePostReferrerContext(); + const referrerPost = usePostReferrerContext()?.referrerPost; useTrackQuestClientEvent({ eventType: ClientQuestEventType.ViewUserProfile, enabled: !!user && !!viewer?.id && viewer.id !== user.id, @@ -113,6 +114,10 @@ export default function ProfileLayout({ // Auto-collapse sidebar on small screens useProfileSidebarCollapse(); + // Record this profile visit in the sidebar Recent section only after the + // profile has resolved (avoids 404s and reserved top-level slugs). + useRecordRecentUserVisit(user); + useEffect(() => { if (trackedView || !user) { return; @@ -149,12 +154,14 @@ export default function ProfileLayout({ {children}
); @@ -165,7 +172,7 @@ export const getLayout = ( props: ProfileLayoutProps, ): ReactNode => getFooterNavBarLayout( - getMainLayout({page}, null, { + getMainLayout({page}, undefined, { screenCentered: false, customBanner: , }), @@ -184,7 +191,10 @@ export async function getStaticProps({ }: GetStaticPropsContext): Promise< GetStaticPropsResult> > { - const { userId } = params; + const userId = params?.userId; + if (!userId) { + return { props: { noindex: true }, revalidate: 60 }; + } try { const user = await getProfile(userId); if (!user) { diff --git a/packages/webapp/components/layouts/SettingsLayout/index.tsx b/packages/webapp/components/layouts/SettingsLayout/index.tsx index 7abdf0b734..614ef53461 100644 --- a/packages/webapp/components/layouts/SettingsLayout/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/index.tsx @@ -105,12 +105,15 @@ export default function SettingsLayout({ } if (!profile) { + if (!formRef) { + return null; + } return (
From e2754f730b3a609d322aa5c72f806aef143b9fde Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 17:56:26 +0300 Subject: [PATCH 23/34] style(sidebar): match rail hover panel to support dropdown Switch the rail hover preview to the same solid styling as the sidebar Support dropdown: - bg-accent-pepper-subtlest (solid in both themes) instead of bg-surface-float (which is 92% transparent and let the underlying feed/sidebar bleed through, hurting legibility) - border-border-subtlest-tertiary + rounded-10 (matches Support) - Wider (w-72) so the panel stays comfortably readable when it overlays the main feed area Co-authored-by: Cursor --- .../shared/src/components/sidebar/RailHoverPanel.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/components/sidebar/RailHoverPanel.tsx b/packages/shared/src/components/sidebar/RailHoverPanel.tsx index aef43d249a..5238229929 100644 --- a/packages/shared/src/components/sidebar/RailHoverPanel.tsx +++ b/packages/shared/src/components/sidebar/RailHoverPanel.tsx @@ -15,11 +15,11 @@ export interface RailHoverPanelProps { // section list) but rendered as a portaled card so the user can peek at // any category's content without changing the pinned selection. // -// Styling: uses `bg-surface-float` (an elevated surface that's lighter -// than the rail backdrop in dark mode and darker in light mode), a -// stronger `shadow-3`, and a higher-contrast border so the popover lifts -// clearly off the sidebar instead of melting into it. The card is -// capped to the viewport height with an internal scroll area. +// Styling matches the sidebar Support dropdown: solid +// `bg-accent-pepper-subtlest` (NOT the translucent `surface-float`), +// `border-border-subtlest-tertiary`, `rounded-10`, plus a stronger +// `shadow-3` to lift it off the rail. Wider than the docked panel so +// it stays comfortably readable when it overlays the feed. export const RailHoverPanel = ({ title, children, @@ -27,7 +27,7 @@ export const RailHoverPanel = ({ }: RailHoverPanelProps) => (
From 2526de662cdd5cd7504ccd7437fa9442d3bca4ad Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 18:19:21 +0300 Subject: [PATCH 24/34] style(sidebar): tighten rail hover panel to match Support dropdown The rail hover preview reused the docked-sidebar Section/SidebarItem markup as-is, which made every row feel oversized and inconsistent inside the small floating dropdown: - mx-3 on each item collided with the panel's own padding, leaving a large empty gutter on the left - typo-callout text + h-9/h-10 rows + w-9 icon containers + h-5 icons are tuned for the full-width docked panel, not a 288px popover Match the Support dropdown's compact density via scoped descendant overrides on the panel content wrapper: - mx-3 -> mx-0 (panel's own p-2 provides the gutter) - typo-callout -> typo-subhead, rows -> h-8, icons -> w-7/h-7 with h-4/w-4 svgs, NavHeader -> h-7 - a/button get px-2 inner padding so labels don't hug the edge Also swap the bold body title for a Footnote/Quaternary section header so the panel feels like a peer of the Support menu rather than a heavier "page header" treatment. Co-authored-by: Cursor --- .../src/components/sidebar/RailHoverPanel.tsx | 68 +++++++++++++++---- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/components/sidebar/RailHoverPanel.tsx b/packages/shared/src/components/sidebar/RailHoverPanel.tsx index 5238229929..423c2318a3 100644 --- a/packages/shared/src/components/sidebar/RailHoverPanel.tsx +++ b/packages/shared/src/components/sidebar/RailHoverPanel.tsx @@ -1,7 +1,11 @@ import type { ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; -import { Typography, TypographyType } from '../typography/Typography'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; import { Nav, SidebarScrollWrapper } from './common'; export interface RailHoverPanelProps { @@ -10,16 +14,47 @@ export interface RailHoverPanelProps { className?: string; } +// Compact-mode overrides for the docked-sidebar Section/SidebarItem +// markup so it looks polished inside this small floating dropdown +// instead of using the heavy mx-3 / w-9 icon / typo-callout sizing. +// +// We use broad descendant selectors with `!important` so the override +// wins over the breakpoint-prefixed defaults (e.g. `laptop:h-9`) baked +// into NavItem/SidebarItem. Targets the class strings emitted by +// `./common.tsx` to match the Support dropdown's row size, icon size, +// and inner padding. +const compactOverrides = [ + // Each SidebarItem renders on + // an
  • — drop the horizontal margin so rows align with the panel + // edge instead of inheriting the docked-sidebar gutter. + '[&_li.mx-3]:!mx-0', + // The
  • carries `typo-callout`; shrink to subhead for parity with + // Support's row text size. + '[&_li.typo-callout]:!typo-subhead', + // Item button/link defaults to `h-10 laptop:h-9` — tighten everywhere. + '[&_a]:!h-8 [&_button]:!h-8', + // Add a small inner padding so items have breathing room from the + // panel edge after we removed mx-3. + '[&_a]:!px-2 [&_button]:!px-2', + // Icon container `w-9 h-9` → `w-7 h-7`, icon `h-5 w-5` → `h-4 w-4`. + '[&_span.h-9]:!h-7 [&_span.w-9]:!w-7', + '[&_svg.h-5]:!h-4 [&_svg.w-5]:!w-4', + // NavSection adds `mt-1` between groups; tighten. + '[&_ul.mt-1]:!mt-0.5', + // Section header row (NavHeader is `h-9 flex items-center`) → tighter. + '[&_li.h-9]:!h-7', +].join(' '); + // Floating preview of a sidebar category, shown on rail hover. // Mirrors the dedicated panel structure (title header + scrollable // section list) but rendered as a portaled card so the user can peek at // any category's content without changing the pinned selection. // -// Styling matches the sidebar Support dropdown: solid -// `bg-accent-pepper-subtlest` (NOT the translucent `surface-float`), -// `border-border-subtlest-tertiary`, `rounded-10`, plus a stronger -// `shadow-3` to lift it off the rail. Wider than the docked panel so -// it stays comfortably readable when it overlays the feed. +// Visual design follows the sidebar Support dropdown: solid +// `bg-accent-pepper-subtlest`, `border-border-subtlest-tertiary`, +// `rounded-10`, `shadow-3`, plus uniform `p-2` outer padding and a +// compact-density override scope so the section content matches +// Support's row size, icon size, and inner padding. export const RailHoverPanel = ({ title, children, @@ -27,17 +62,22 @@ export const RailHoverPanel = ({ }: RailHoverPanelProps) => (
    -
    - - {title} - -
    - - + + {title} + + +
    ); From fb2095593e27632defb6738be4962f6fb0ee9163 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 18:25:02 +0300 Subject: [PATCH 25/34] feat(sidebar): rail icon click navigates to category landing page Clicking a rail category icon now both selects the dedicated panel AND opens the category's first navigable page, so the panel selection matches what the user sees in the main content area: - Home -> / - Squads -> /squads (Find Squads) - Discover-> /tags (skipping the Hot Takes modal trigger so we land on a real page, not "/" with a dialog over it) - Saved -> /bookmarks (Quick saves) - Game -> /game-center - Profile -> /:username (only when the user is logged in) Settings keeps its existing navigation. Also fix the panel flicker that occurred when transitioning between non-Main routes (e.g. /squads -> click Profile rail): The prior `useState(resolveCategory) + useEffect` pattern would re-resolve the category from the OLD URL while router.push was still in flight and snap the panel back to Squads for one render before the URL caught up and snapped it to Profile. Replaced with an optimistic `pendingCategory` override that wins over the URL-derived `resolvedCategory` until the route navigation lands, at which point an effect clears the override so back/forward and deep-link changes still drive the panel correctly. Co-authored-by: Cursor --- .../src/components/sidebar/SidebarDesktop.tsx | 83 ++++++++++++++++--- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index eca5ad910e..ad084f598c 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -69,12 +69,19 @@ type SidebarCategoryConfig = { id: SidebarSelectedCategory; label: string; icon: (active: boolean) => ReactElement; + // Landing path for the rail icon click. Maps each category to the + // first navigable item shown in its dedicated panel, so clicking a + // rail icon both selects the panel AND opens the matching page (the + // first item then highlights as active via `activePage` matching). + // Profile is dynamic (needs username) and handled at click time. + defaultPath?: string; }; const sidebarCategories: SidebarCategoryConfig[] = [ { id: SidebarSelectedCategory.Main, label: 'Home', + defaultPath: webappUrl, icon: (active) => ( ( ), @@ -94,6 +102,11 @@ const sidebarCategories: SidebarCategoryConfig[] = [ { id: SidebarSelectedCategory.Discover, label: 'Discover', + // First DiscoverSection item is "Hot Takes" — but it opens a modal + // rather than navigating, which would land the user on `/` with a + // dialog over it. Use the first real page instead so the URL + + // activePage actually moves to a Discover surface. + defaultPath: `${webappUrl}tags`, icon: (active) => ( ), @@ -101,6 +114,7 @@ const sidebarCategories: SidebarCategoryConfig[] = [ { id: SidebarSelectedCategory.Saved, label: 'Saved', + defaultPath: `${webappUrl}bookmarks`, icon: (active) => ( ), @@ -108,6 +122,7 @@ const sidebarCategories: SidebarCategoryConfig[] = [ { id: SidebarSelectedCategory.GameCenter, label: 'Game Center', + defaultPath: `${webappUrl}game-center`, icon: (active) => ( ), @@ -321,7 +336,7 @@ export const SidebarDesktop = ({ !!user?.username && activePage.includes(`/${user.username}`); const isFeedPage = activePage.includes('/feeds/'); - const resolveCategory = useCallback((): SidebarSelectedCategory => { + const resolvedCategory = useMemo((): SidebarSelectedCategory => { if (isFeedPage) { return SidebarSelectedCategory.Main; } @@ -340,11 +355,26 @@ export const SidebarDesktop = ({ isUserProfileActive, ]); - const [selectedCategory, setSelectedCategory] = useState(resolveCategory); + // `pendingCategory` is an optimistic override applied the moment a + // rail icon is clicked, so the panel switches instantly even though + // `router.push` is async. Without it we'd flicker: + // 1. click Profile -> setState(Profile) + // 2. settings flag updates synchronously -> re-render + // 3. URL hasn't navigated yet, so `getSidebarCategoryForPath` still + // returns the OLD route's category and would clobber Profile + // 4. URL eventually updates -> snaps back to Profile + // The override stays in place until the resolved category catches up + // (URL navigated AND/OR flag landed), then it's cleared so future + // route changes from elsewhere (back button, deep link) take over. + const [pendingCategory, setPendingCategory] = + useState(null); + const selectedCategory = pendingCategory ?? resolvedCategory; useEffect(() => { - setSelectedCategory(resolveCategory()); - }, [resolveCategory]); + if (pendingCategory !== null && pendingCategory === resolvedCategory) { + setPendingCategory(null); + } + }, [pendingCategory, resolvedCategory]); const defaultRenderSectionProps = useMemo( () => ({ @@ -355,25 +385,52 @@ export const SidebarDesktop = ({ [activePage], ); + const getCategoryDefaultPath = useCallback( + (category: SidebarSelectedCategory): string | null => { + if (category === SidebarSelectedCategory.Profile) { + return user?.username ? `${webappUrl}${user.username}` : null; + } + return ( + sidebarCategories.find((entry) => entry.id === category)?.defaultPath ?? + null + ); + }, + [user?.username], + ); + const onSelectCategory = useCallback( (category: SidebarSelectedCategory) => { - setSelectedCategory(category); - // GameCenter and Settings are navigations, not persistent panel - // states: GameCenter routes to /game-center, Settings is rendered - // wherever the user lands under /settings. Persisting them to the - // server flag is wasted writes and noise — the value would be - // normalised back to Main on the next read anyway. + // Snap the panel immediately so the click feels responsive and so + // the in-flight router.push doesn't race the resolver back to the + // old URL's category. + setPendingCategory(category); + + // GameCenter and Settings are pure navigations, not persistent + // panel states: their landing pages drive the highlight via + // `getSidebarCategoryForPath`. Persisting them to the server flag + // would be normalised back to Main on the next read anyway. if ( category !== SidebarSelectedCategory.GameCenter && category !== SidebarSelectedCategory.Settings ) { updateFlag(SidebarSettingsFlags.SelectedCategory, category); } - if (category === SidebarSelectedCategory.GameCenter) { - router.push(`${webappUrl}game-center`).catch(() => undefined); + + const targetPath = getCategoryDefaultPath(category); + if (!targetPath) { + return; + } + // `defaultPath` is an absolute URL (uses `webappUrl`), but + // `activePage` is a router asPath like "/squads?x=1". Compare on + // pathname only so we don't push when already there (the dummy + // origin handles both absolute + relative targets). + const targetPathname = new URL(targetPath, 'http://_').pathname; + const currentPathname = activePage.split('?')[0]; + if (targetPathname !== currentPathname) { + router.push(targetPath).catch(() => undefined); } }, - [router, updateFlag], + [activePage, getCategoryDefaultPath, router, updateFlag], ); const onToggleExpanded = useCallback(() => { From 1424a136202dfc0719296f71a50c2e53a03f61b4 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 18:31:25 +0300 Subject: [PATCH 26/34] style(sidebar): match rail hover panel rows to Support exactly Even after switching to bg-accent-pepper-subtlest the panel still felt "left-padded" compared to the Support dropdown. Two real causes: 1) The docked SidebarItem wraps every icon in a `w-9 h-9` flex box that *centers* the 20px icon in a 36px column. Even after shrinking the wrapper to `w-7`, the icon still floated 6px away from the row's left edge, so the label visually sat far further right than the section title above it. 2) The panel used `p-2` outer padding while Support uses `p-3`, so the title-to-edge measurements were inconsistent between the two dropdowns. Match Support's ProfileSectionItem layout 1:1: - Outer padding p-2 -> p-3 (and update the scroll wrapper's negative margins to follow), title goes from `px-1 pb-1` -> Support's `p-1` - Item rows use `px-1 gap-2` (Support's pattern) instead of the sidebar's `pl-1 pr-3 + centered icon wrapper` - Collapse the `w-9 h-9` icon wrapper to `w-4 h-4` so the icon hugs the row's left edge; `gap-2` then provides the icon-to-label space - Tighten `mt-1` -> `mt-0` between groups and the `h-9` section header -> `h-6` to match Support's compact rhythm Co-authored-by: Cursor --- .../src/components/sidebar/RailHoverPanel.tsx | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/shared/src/components/sidebar/RailHoverPanel.tsx b/packages/shared/src/components/sidebar/RailHoverPanel.tsx index 423c2318a3..0c015778e2 100644 --- a/packages/shared/src/components/sidebar/RailHoverPanel.tsx +++ b/packages/shared/src/components/sidebar/RailHoverPanel.tsx @@ -15,34 +15,42 @@ export interface RailHoverPanelProps { } // Compact-mode overrides for the docked-sidebar Section/SidebarItem -// markup so it looks polished inside this small floating dropdown -// instead of using the heavy mx-3 / w-9 icon / typo-callout sizing. +// markup so it looks polished inside this small floating dropdown. +// +// Goal: match the Support dropdown's row layout, which uses the +// ProfileSectionItem pattern of `px-1 gap-2 h-8` with a flush 16px +// icon (no centering wrapper). The docked sidebar instead wraps every +// icon in a `w-9 h-9` centering box so the icon visually floats in the +// middle of a 36px column — that wrapper is what made the rail hover +// panel look like it had a huge left padding, even after we removed +// the `mx-3` outer gutter. // // We use broad descendant selectors with `!important` so the override // wins over the breakpoint-prefixed defaults (e.g. `laptop:h-9`) baked -// into NavItem/SidebarItem. Targets the class strings emitted by -// `./common.tsx` to match the Support dropdown's row size, icon size, -// and inner padding. +// into NavItem/SidebarItem. const compactOverrides = [ - // Each SidebarItem renders on - // an
  • — drop the horizontal margin so rows align with the panel - // edge instead of inheriting the docked-sidebar gutter. + //
  • from SidebarItem renders with `mx-3 rounded-10` — drop the + // gutter so rows hug the panel's own padding. '[&_li.mx-3]:!mx-0', - // The
  • carries `typo-callout`; shrink to subhead for parity with - // Support's row text size. + //
  • carries `typo-callout`; shrink to subhead for row-text parity. '[&_li.typo-callout]:!typo-subhead', - // Item button/link defaults to `h-10 laptop:h-9` — tighten everywhere. + // Item button/link defaults `h-10 laptop:h-9` — match Support's h-8. '[&_a]:!h-8 [&_button]:!h-8', - // Add a small inner padding so items have breathing room from the - // panel edge after we removed mx-3. - '[&_a]:!px-2 [&_button]:!px-2', - // Icon container `w-9 h-9` → `w-7 h-7`, icon `h-5 w-5` → `h-4 w-4`. - '[&_span.h-9]:!h-7 [&_span.w-9]:!w-7', + // Match Support's tight `px-1 gap-2` row layout (icon + text + + // optional right icon as evenly-spaced flex children) instead of the + // sidebar's `pl-1 pr-3` + centered icon wrapper. + '[&_a]:!px-1 [&_button]:!px-1', + '[&_a]:!gap-2 [&_button]:!gap-2', + // Collapse the icon centering wrapper down to the icon's own size + // so the icon sits at the row's start, then `gap-2` provides the + // breathing room before the label. + '[&_span.h-9]:!h-4 [&_span.w-9]:!w-4', '[&_svg.h-5]:!h-4 [&_svg.w-5]:!w-4', - // NavSection adds `mt-1` between groups; tighten. - '[&_ul.mt-1]:!mt-0.5', - // Section header row (NavHeader is `h-9 flex items-center`) → tighter. - '[&_li.h-9]:!h-7', + // Section list spacing — Support has tighter group rhythm. + '[&_ul.mt-1]:!mt-0', + // Section header (NavHeader is `h-9 flex items-center`) — match the + // Support title's compact `Footnote / p-1` height. + '[&_li.h-9]:!h-6', ].join(' '); // Floating preview of a sidebar category, shown on rail hover. @@ -50,11 +58,12 @@ const compactOverrides = [ // section list) but rendered as a portaled card so the user can peek at // any category's content without changing the pinned selection. // -// Visual design follows the sidebar Support dropdown: solid +// Visual design copies the sidebar Support dropdown 1:1: solid // `bg-accent-pepper-subtlest`, `border-border-subtlest-tertiary`, -// `rounded-10`, `shadow-3`, plus uniform `p-2` outer padding and a +// `rounded-10`, `shadow-3`, uniform `p-3` outer padding, and a +// `Footnote / Quaternary / p-1` section title — paired with the // compact-density override scope so the section content matches -// Support's row size, icon size, and inner padding. +// Support's row size, icon size, gap, and inner padding. export const RailHoverPanel = ({ title, children, @@ -62,7 +71,7 @@ export const RailHoverPanel = ({ }: RailHoverPanelProps) => (
    @@ -70,11 +79,11 @@ export const RailHoverPanel = ({ bold type={TypographyType.Footnote} color={TypographyColor.Quaternary} - className="px-1 pb-1" + className="p-1" > {title} - + From a5c3722f76c8f65381f568abe8f92bec408058ec Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 12 May 2026 18:35:34 +0300 Subject: [PATCH 27/34] fix(sidebar): squads rail click is instant + first item highlights Two fixes for the rail-icon -> dedicated-panel handoff that the Squads category exposed most clearly: 1) Skip the /squads -> /squads/discover server redirect `pages/squads/index.tsx` is just a `getServerSideProps` 308 to `/squads/discover`, so router.push('/squads') paid for a server roundtrip on every Squads click. Point both the rail's `defaultPath` AND the "Find Squads" item path directly at `/squads/discover` so the navigation is one hop. 2) "Find Squads" wasn't highlighting after the click SidebarItem matches active state via `item.path === activePage`. Because the redirect landed the user on `/squads/discover` while the item path was `/squads`, the comparison always failed and the first row never lit up. Aligning both to `/squads/discover` makes the row highlight the moment the page resolves, so the user can tell what they just opened. Also prefetch each category's landing page on rail-icon hover/focus (router.prefetch) so the click-to-page transition feels instant in production. No-op in dev, but priming the JS chunk + RSC payload in prod removes the perceived "loading between the icons until the page". Co-authored-by: Cursor --- .../src/components/sidebar/SidebarDesktop.tsx | 23 ++++++++++++++++++- .../sidebar/sections/NetworkSection.tsx | 6 ++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index ad084f598c..72d7e8ecbb 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -94,7 +94,11 @@ const sidebarCategories: SidebarCategoryConfig[] = [ { id: SidebarSelectedCategory.Squads, label: 'Squads', - defaultPath: `${webappUrl}squads`, + // `/squads` server-side redirects to `/squads/discover` — link + // straight to the destination so we skip the round-trip latency + // AND so the resolved `activePage` matches the "Find Squads" item + // path (which we also point at `/squads/discover`). + defaultPath: `${webappUrl}squads/discover`, icon: (active) => ( ), @@ -433,6 +437,21 @@ export const SidebarDesktop = ({ [activePage, getCategoryDefaultPath, router, updateFlag], ); + // Warm the route on hover so the click-to-page transition feels + // instant. Next.js router.prefetch is a no-op in development unless + // explicitly enabled, but in production it primes the JS chunk + RSC + // payload for the destination so navigation skips the network wait. + const onPrefetchCategory = useCallback( + (category: SidebarSelectedCategory) => { + const targetPath = getCategoryDefaultPath(category); + if (!targetPath) { + return; + } + router.prefetch(targetPath).catch(() => undefined); + }, + [getCategoryDefaultPath, router], + ); + const onToggleExpanded = useCallback(() => { logEvent({ event_name: `${sidebarExpanded ? 'open' : 'close'} sidebar`, @@ -633,6 +652,8 @@ export const SidebarDesktop = ({ aria-label={category.label} aria-selected={isSelected} onClick={() => onSelectCategory(category.id)} + onMouseEnter={() => onPrefetchCategory(category.id)} + onFocus={() => onPrefetchCategory(category.id)} className={classNames( railButtonClass, isSelected && 'bg-background-default text-white', diff --git a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx index 0762ae8be2..edcf8214cd 100644 --- a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx @@ -65,7 +65,11 @@ export const NetworkSection = ({ } /> ), title: 'Find Squads', - path: `${webappUrl}squads`, + // Match where the page actually lives — `/squads` is a 308 to + // `/squads/discover`, so the `activePage === item.path` check + // in SidebarItem only highlights when we link straight to the + // destination. + path: `${webappUrl}squads/discover`, isForcedLink: true, }, ...squadItems, From dd5907c1660094767d3d6bb95d9334ec85a989e8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 13 May 2026 10:17:54 +0300 Subject: [PATCH 28/34] feat(layout): unify page header strip across feed pages Introduce a shared PageHeader primitive matching the homepage feed list-frame (compact, bordered, content-driven row) and apply it to the squad, bookmarks and game center pages so every primary surface gets the same slim header treatment. Refactor SearchControlHeader so the Clickbait Shield button sizes itself to its content the same way the Feed settings button does (compact text variant for the non-Plus "X/Y" CTA, square 32px icon variant for the Plus toggle), eliminating the bespoke min-w-8 + px-2 outlier. Co-authored-by: Cursor --- .../src/components/BookmarkFeedLayout.tsx | 79 +++++++++++-------- .../buttons/ToggleClickbaitShield.tsx | 9 +++ .../src/components/feeds/FeedContainer.tsx | 23 ++---- .../src/components/layout/PageHeader.tsx | 52 ++++++++++++ .../shared/src/components/layout/common.tsx | 37 ++++++--- packages/webapp/__tests__/SquadFeedPage.tsx | 6 +- packages/webapp/pages/game-center/index.tsx | 27 +++---- .../webapp/pages/squads/[handle]/index.tsx | 4 + 8 files changed, 160 insertions(+), 77 deletions(-) create mode 100644 packages/shared/src/components/layout/PageHeader.tsx diff --git a/packages/shared/src/components/BookmarkFeedLayout.tsx b/packages/shared/src/components/BookmarkFeedLayout.tsx index ef12cf9a81..2fbb13c593 100644 --- a/packages/shared/src/components/BookmarkFeedLayout.tsx +++ b/packages/shared/src/components/BookmarkFeedLayout.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren, ReactElement, ReactNode } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useCallback, useContext, @@ -16,12 +16,12 @@ import { } from '../graphql/feed'; import { ClientQuestEventType } from '../graphql/quests'; import AuthContext from '../contexts/AuthContext'; -import { CustomFeedHeader, FeedPageHeader } from './utilities'; +import { CustomFeedHeader } from './utilities'; +import { PageHeader } from './layout/PageHeader'; import SearchEmptyScreen from './SearchEmptyScreen'; import type { FeedProps } from './Feed'; import Feed from './Feed'; import BookmarkEmptyScreen from './BookmarkEmptyScreen'; -import type { ButtonProps } from './buttons/Button'; import { Button, ButtonSize, ButtonVariant } from './buttons/Button'; import { ShareIcon, SortIcon } from './icons'; import { generateQueryKey, OtherFeedPage, RequestKey } from '../lib/query'; @@ -43,6 +43,11 @@ import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { Dropdown } from './fields/Dropdown'; import { IconSize } from './Icon'; +const compactIconButtonClassName = + '!size-8 !rounded-10 !border-transparent !bg-transparent !p-0 hover:!bg-surface-hover'; +const compactTextButtonClassName = + '!h-8 !rounded-10 !border-transparent !bg-transparent !px-3 hover:!bg-surface-hover'; + export type BookmarkFeedLayoutProps = { isReminderOnly?: boolean; searchQuery?: string; @@ -60,17 +65,6 @@ const SharedBookmarksModal = dynamic( ), ); -const ShareBookmarksButton = ({ - children, - ...props -}: PropsWithChildren< - Pick, 'className' | 'onClick' | 'icon'> ->) => ( - -); - const bookmarkSortOptions = [ { label: 'Newest first', value: BookmarkSort.TimeDesc }, { label: 'Oldest first', value: BookmarkSort.TimeAsc }, @@ -193,51 +187,66 @@ export default function BookmarkFeedLayout({ return ( {children} - - - {title} - - - + {title} + + } > - {searchChildren} {!isSearchResults && ( } + icon={} iconOnly selectedIndex={selectedSort} options={bookmarkSortOptionLabels} onChange={(_, index) => setSelectedSort(index)} - buttonVariant={ButtonVariant.Float} - buttonSize={ButtonSize.Medium} + buttonVariant={ButtonVariant.Tertiary} + buttonSize={ButtonSize.Small} drawerProps={{ displayCloseButton: true }} /> )} {!isFolderPage && ( - } + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + className={ + isLaptop ? compactTextButtonClassName : compactIconButtonClassName + } + icon={ + + } onClick={() => setShowSharedBookmarks(true)} > {isLaptop ? Share bookmarks : null} - + )} {folder && !isReminderOnly && ( )} - + + {searchChildren && ( + + {searchChildren} + + )} {showSharedBookmarks && ( ; + /** + * Applied only to the Plus (icon-only) render path on top of `buttonProps`. + * Use to keep the icon-only case sized like sibling icon-buttons in compact + * headers without forcing min-widths on the non-Plus "X/Y" text variant. + */ + iconButtonProps?: ButtonProps<'button'>; iconSize?: IconProps['size']; }): ReactElement => { const queryClient = useQueryClient(); @@ -97,6 +105,7 @@ export const ToggleClickbaitShield = ({ >
    diff --git a/packages/shared/src/components/layout/PageHeader.tsx b/packages/shared/src/components/layout/PageHeader.tsx new file mode 100644 index 0000000000..dc4ba71fa7 --- /dev/null +++ b/packages/shared/src/components/layout/PageHeader.tsx @@ -0,0 +1,52 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +export interface PageHeaderProps { + /** + * Left-side title. A plain string is wrapped in a bold callout + * (matching the homepage feed header). For custom typography pass + * a ReactNode and it will render inside a truncating flex slot. + */ + title?: ReactNode; + /** + * Right-side actions (buttons, dropdowns, etc.). Docked to the + * end of the row with shrink-0 so the title takes the remaining + * space and truncates first. + */ + children?: ReactNode; + className?: string; +} + +/** + * Shared "page header" strip used at the top of the floating card on + * every primary page (home, squads, bookmarks, game center, ...). + * Matches the homepage feed list-frame: bottom border, px-6 py-3, + * title on the left, action buttons docked right. + */ +export const PageHeader = ({ + title, + children, + className, +}: PageHeaderProps): ReactElement => ( +
    + {title !== undefined && + (typeof title === 'string' ? ( + + {title} + + ) : ( +
    {title}
    + ))} + {children !== undefined && ( +
    + {children} +
    + )} +
    +); diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index 822c9628f2..fd9aa59978 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -5,7 +5,6 @@ import type { SetStateAction, } from 'react'; import React, { useContext } from 'react'; -import classNames from 'classnames'; import classed from '../../lib/classed'; import { SharedFeedPage } from '../utilities'; import MyFeedHeading from '../filters/MyFeedHeading'; @@ -136,12 +135,18 @@ export const SearchControlHeader = ({ key="install-extension" tag="a" href={downloadBrowserExtension} - variant={isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary} - size={ButtonSize.Medium} - icon={isEdge ? : } + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + icon={ + isEdge ? ( + + ) : ( + + ) + } rel={anchorDefaultRel} target="_blank" - className="ml-auto" + className={isLaptop ? compactTextButtonClassName : undefined} onClick={() => logEvent({ event_name: LogEvent.DownloadExtension, @@ -154,7 +159,9 @@ export const SearchControlHeader = ({ + + {dailyTrio.map((member, index) => ( + + ))} + + + + {isSidebar && ( + + )} +
  • ); } return ( + + + + + + ); +}; + const SidebarSupportButton = (): ReactElement => { const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); @@ -276,6 +359,7 @@ const SidebarSupportButton = (): ReactElement => { position={InteractivePopupPosition.SidebarSupportMenu} className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" > + @@ -285,26 +369,6 @@ const SidebarSupportButton = (): ReactElement => { ); }; -const SidebarStreakButton = (): ReactElement | null => { - const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); - - if (!isStreaksEnabled || !streak) { - return null; - } - - return ( - - ); -}; - type SidebarDesktopProps = { activePage?: string; featureTheme?: { @@ -394,6 +458,9 @@ export const SidebarDesktop = ({ if (category === SidebarSelectedCategory.Profile) { return user?.username ? `${webappUrl}${user.username}` : null; } + if (category === SidebarSelectedCategory.Settings) { + return settingsDefaultPath; + } return ( sidebarCategories.find((entry) => entry.id === category)?.defaultPath ?? null @@ -506,7 +573,15 @@ export const SidebarDesktop = ({ if (category === SidebarSelectedCategory.Profile) { return ( - + <> +
    + +
    + + ); } @@ -572,7 +647,7 @@ export const SidebarDesktop = ({ /> @@ -732,16 +819,31 @@ export const SidebarDesktop = ({ )} > {isHomePanel ? ( -
    - {isLoggedIn ? ( - - ) : ( -
    - )} - -
    - {isLoggedIn && } - +
    + ) : ( -
    - - {utilityPanelTitle} - -
    - {isSquadsPanel && ( - +
    +
    + + {utilityPanelTitle} + +
    + {isSquadsPanel && ( + + + + )} + - )} - - - +
    )} - {!isUtilityPanelSelected && ( -
    - + {isHomePanel && isLoggedIn && ( +
    +
    )} + {!isUtilityPanelSelected && ( + <> +
    + +
    + + + )} + {isLoggedIn && !isUtilityPanelSelected && additionalButtons && (
    {additionalButtons} @@ -804,11 +917,14 @@ export const SidebarDesktop = ({ - + {selectedCategory === SidebarSelectedCategory.Main && (
    diff --git a/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx new file mode 100644 index 0000000000..3b0d461e0a --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx @@ -0,0 +1,79 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useReadingStreak } from '../../hooks/streaks'; +import { walletUrl } from '../../lib/constants'; +import { largeNumberFormat } from '../../lib'; +import { formatCurrency } from '../../lib/utils'; +import Link from '../utilities/Link'; +import { Tooltip } from '../tooltip/Tooltip'; +import { IconSize } from '../Icon'; +import { CoreIcon, ReadingStreakIcon, ReputationIcon } from '../icons'; + +const slotClass = + 'focus-outline flex h-9 w-full items-center justify-center gap-1 rounded-10 font-bold text-text-primary typo-footnote transition-colors hover:bg-surface-hover'; + +// Compact stats strip rendered under the user identity row in the dedicated +// home panel. Streak / reputation / cores share an equal-column grid so each +// slot stretches to the same width across the panel. +export const SidebarHeaderStats = (): ReactElement | null => { + const { user } = useAuthContext(); + const { streak, isStreaksEnabled } = useReadingStreak(); + + 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; + + return ( +
    + {showStreak && ( + + + + {streakValue} + + + )} + + + + {largeNumberFormat(reputation)} + + + + Wallet +
    + {preciseBalance} Cores + + } + side="bottom" + > + + + + {largeNumberFormat(balance)} + + +
    +
    + ); +}; diff --git a/packages/shared/src/components/sidebar/SidebarProfileCompletion.tsx b/packages/shared/src/components/sidebar/SidebarProfileCompletion.tsx new file mode 100644 index 0000000000..968e32629b --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarProfileCompletion.tsx @@ -0,0 +1,91 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import ProgressCircle from '../ProgressCircle'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { IconSize } from '../Icon'; +import { ClearIcon } from '../icons'; +import Link from '../utilities/Link'; +import { anchorDefaultRel } from '../../lib/strings'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useProfileCompletionIndicator } from '../../hooks/profile/useProfileCompletionIndicator'; +import { + formatCompletionDescription, + getCompletionItems, +} from '../../lib/profileCompletion'; + +// Sidebar-native profile completion card. Lives at the top of the dedicated +// Profile panel and is intentionally flat (no fill) with a subtle border so +// it blends with the rest of the sidebar list. Mirrors the hover-revealed +// dismiss treatment used by the sidebar feedback button. +export const SidebarProfileCompletion = (): ReactElement | null => { + const { user } = useAuthContext(); + const { showIndicator, dismissIndicator } = useProfileCompletionIndicator(); + const profileCompletion = user?.profileCompletion; + + const items = useMemo( + () => (profileCompletion ? getCompletionItems(profileCompletion) : []), + [profileCompletion], + ); + const incompleteItems = useMemo( + () => items.filter((item) => !item.completed), + [items], + ); + const description = useMemo( + () => formatCompletionDescription(incompleteItems), + [incompleteItems], + ); + + if (!showIndicator || !profileCompletion) { + return null; + } + + const progress = profileCompletion.percentage ?? 0; + const redirectPath = incompleteItems[0]?.redirectPath; + + return ( + + ); +}; diff --git a/packages/shared/src/components/sidebar/sections/RecentSection.tsx b/packages/shared/src/components/sidebar/sections/RecentSection.tsx index 44ad085f5a..27a9624fa4 100644 --- a/packages/shared/src/components/sidebar/sections/RecentSection.tsx +++ b/packages/shared/src/components/sidebar/sections/RecentSection.tsx @@ -13,9 +13,17 @@ import { Section } from '../Section'; import type { SidebarSectionProps } from './common'; import { useRecentPages, + type RecentPage, type RecentPageKind, } from '../../../hooks/feed/useRecentPages'; import { SidebarSettingsFlags } from '../../../graphql/settings'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useFeeds } from '../../../hooks'; +import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; +import { SquadImage } from '../../squads/SquadImage'; +import { Image, ImageType } from '../../image/Image'; +import type { Feed } from '../../../graphql/feed'; +import type { Squad } from '../../../graphql/sources'; const iconByKind: Record< RecentPageKind, @@ -28,26 +36,169 @@ const iconByKind: Record< user: UserIcon, }; +const getPathSegment = (path: string, segment: string): string | undefined => { + const parts = path.split(/[?#]/)[0]?.split('/').filter(Boolean) ?? []; + const segmentIndex = parts.indexOf(segment); + const value = parts[segmentIndex + 1]; + return value ? decodeURIComponent(value) : undefined; +}; + +const getSquadHandle = (page: RecentPage): string | undefined => { + if (page.entity?.type === 'squad') { + return page.entity.handle; + } + + return getPathSegment(page.path, 'squads'); +}; + +const getFeed = ( + page: RecentPage, + feedByIdOrSlug: Map, +): Feed | undefined => { + const slugOrId = getPathSegment(page.path, 'feeds'); + if (!slugOrId) { + return undefined; + } + + return feedByIdOrSlug.get(slugOrId); +}; + +const RecentSourceImage = ({ + src, + alt, +}: { + src?: string | null; + alt: string; +}): ReactElement => ( + {alt} +); + +const getPageTitle = ( + page: RecentPage, + squadByHandle: Map, + feedByIdOrSlug: Map, +): string => { + if (page.entity?.name) { + return page.entity.name; + } + + if (page.kind === 'squad') { + const handle = getSquadHandle(page); + const squad = handle ? squadByHandle.get(handle.toLowerCase()) : undefined; + return squad?.name ?? page.title; + } + + if (page.kind === 'feed') { + const feed = getFeed(page, feedByIdOrSlug); + return feed?.flags?.name || page.title; + } + + return page.title; +}; + +const getPageIcon = ({ + page, + isActive, + squadByHandle, + feedByIdOrSlug, +}: { + page: RecentPage; + isActive: boolean; + squadByHandle: Map; + feedByIdOrSlug: Map; +}): SidebarMenuItem['icon'] => { + const { entity } = page; + + if (entity?.type === 'user') { + return ( + + ); + } + + if (page.kind === 'squad') { + const handle = getSquadHandle(page); + const squad = handle ? squadByHandle.get(handle.toLowerCase()) : undefined; + + if (squad || handle) { + return ( + + ); + } + } + + if (entity?.type === 'source') { + return ( + + ); + } + + if (page.kind === 'feed') { + const feed = getFeed(page, feedByIdOrSlug); + if (feed?.flags?.icon) { + return feed.flags.icon; + } + } + + const Icon = iconByKind[page.kind]; + return } />; +}; + export const RecentSection = ({ isItemsButton, ...defaultRenderSectionProps }: SidebarSectionProps): ReactElement | null => { const pages = useRecentPages(); + const { squads } = useAuthContext(); + const { feeds } = useFeeds(); - const menuItems: SidebarMenuItem[] = useMemo( - () => - pages.map((page) => { - const Icon = iconByKind[page.kind]; - const isActive = defaultRenderSectionProps.activePage === page.path; - return { - title: page.title, - path: page.path, - icon: () => } />, - active: isActive, - }; - }), - [pages, defaultRenderSectionProps.activePage], - ); + const menuItems: SidebarMenuItem[] = useMemo(() => { + const squadByHandle = new Map( + squads?.map((squad) => [squad.handle.toLowerCase(), squad]) ?? [], + ); + const feedByIdOrSlug = new Map(); + feeds?.edges.forEach(({ node }) => { + feedByIdOrSlug.set(node.id, node); + feedByIdOrSlug.set(node.slug, node); + }); + + return pages.map((page) => { + const isActive = defaultRenderSectionProps.activePage === page.path; + return { + title: getPageTitle(page, squadByHandle, feedByIdOrSlug), + path: page.path, + icon: getPageIcon({ + page, + isActive, + squadByHandle, + feedByIdOrSlug, + }), + active: isActive, + }; + }); + }, [pages, squads, feeds?.edges, defaultRenderSectionProps.activePage]); if (menuItems.length === 0) { return null; diff --git a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx index 80c8161fe2..35f041ab17 100644 --- a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx @@ -39,6 +39,8 @@ import { LazyModal } from '../../modals/common/types'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent, TargetId } from '../../../lib/log'; +const settingsDefaultPath = `${settingsUrl}/profile`; + type SettingsGroup = { key: string; title?: string; @@ -59,7 +61,7 @@ export const SettingsPanelSection = ({ items: [ { title: 'Profile details', - path: `${settingsUrl}/profile`, + path: settingsDefaultPath, icon: (active: boolean) => ( } /> ), diff --git a/packages/shared/src/components/squads/SquadImage.tsx b/packages/shared/src/components/squads/SquadImage.tsx index 24574cc83e..4b99e36022 100644 --- a/packages/shared/src/components/squads/SquadImage.tsx +++ b/packages/shared/src/components/squads/SquadImage.tsx @@ -5,8 +5,9 @@ import type { Squad } from '../../graphql/sources'; import { Image } from '../image/Image'; import { cloudinarySquadsImageFallback } from '../../lib/image'; -interface SquadImageProps extends Squad { +interface SquadImageProps extends Pick { className?: string; + image?: string | null; } export const SquadImage = ({ diff --git a/packages/shared/src/hooks/feed/useRecentPages.ts b/packages/shared/src/hooks/feed/useRecentPages.ts index 8eaafd3633..32a56626db 100644 --- a/packages/shared/src/hooks/feed/useRecentPages.ts +++ b/packages/shared/src/hooks/feed/useRecentPages.ts @@ -1,23 +1,49 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import { del as delCache, get as getCache, set as setCache } from 'idb-keyval'; +import type { Source, Squad } from '../../graphql/sources'; +import type { PublicProfile } from '../../lib/user'; export const RECENT_PAGES_LIMIT = 5; export const RECENT_PAGES_STORAGE_KEY = 'daily.recentPages'; export type RecentPageKind = 'squad' | 'tag' | 'source' | 'feed' | 'user'; +export type RecentPageEntity = + | { + type: 'squad'; + handle: string; + image?: string | null; + name?: string | null; + } + | { + type: 'source'; + handle: string; + image?: string | null; + name?: string | null; + } + | { + type: 'user'; + username: string; + image?: string | null; + name?: string | null; + }; + export interface RecentPage { path: string; title: string; kind: RecentPageKind; visitedAt: number; + entity?: RecentPageEntity; } +type RecentPageInput = Pick & + Partial>; + type Detector = ( query: Record, asPath: string, -) => Pick | null; +) => RecentPageInput | null; const firstValue = ( value: string | string[] | undefined, @@ -71,7 +97,7 @@ const detectRecentPage = ( pathname: string, query: Record, asPath: string, -): Pick | null => { +): RecentPageInput | null => { const detector = detectors[pathname]; if (!detector) { return null; @@ -79,19 +105,81 @@ const detectRecentPage = ( return detector(query, asPath); }; +const isRecentPageKind = (value: unknown): value is RecentPageKind => + value === 'squad' || + value === 'tag' || + value === 'source' || + value === 'feed' || + value === 'user'; + +const sanitizeStoredEntity = (value: unknown): RecentPageEntity | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const entity = value as { + type?: unknown; + handle?: unknown; + username?: unknown; + image?: unknown; + name?: unknown; + }; + if (entity.type === 'user' && typeof entity.username === 'string') { + return { + type: 'user', + username: entity.username, + image: typeof entity.image === 'string' ? entity.image : undefined, + name: typeof entity.name === 'string' ? entity.name : undefined, + }; + } + + if ( + (entity.type === 'squad' || entity.type === 'source') && + typeof entity.handle === 'string' + ) { + return { + type: entity.type, + handle: entity.handle, + image: typeof entity.image === 'string' ? entity.image : undefined, + name: typeof entity.name === 'string' ? entity.name : undefined, + }; + } + + return undefined; +}; + +// Recent pages should never point to a single post. Earlier versions could +// store `/posts/{id}` under a squad/source entity when a post modal was opened +// on top of that entity's feed (router.asPath is swapped via shallow routing). +// Strip those out on read so legacy entries clear themselves over time. +const isFeedLikePath = (path: string): boolean => !path.startsWith('/posts/'); + const sanitizeStoredPages = (value: unknown): RecentPage[] => { if (!Array.isArray(value)) { return []; } - return value.filter( - (entry): entry is RecentPage => - !!entry && - typeof entry === 'object' && - typeof (entry as RecentPage).path === 'string' && - typeof (entry as RecentPage).title === 'string' && - typeof (entry as RecentPage).kind === 'string' && - typeof (entry as RecentPage).visitedAt === 'number', - ); + return value.reduce((pages, entry) => { + if ( + !entry || + typeof entry !== 'object' || + typeof (entry as RecentPage).path !== 'string' || + typeof (entry as RecentPage).title !== 'string' || + !isRecentPageKind((entry as RecentPage).kind) || + typeof (entry as RecentPage).visitedAt !== 'number' || + !isFeedLikePath((entry as RecentPage).path) + ) { + return pages; + } + + pages.push({ + path: (entry as RecentPage).path, + title: (entry as RecentPage).title, + kind: (entry as RecentPage).kind, + visitedAt: (entry as RecentPage).visitedAt, + entity: sanitizeStoredEntity((entry as RecentPage).entity), + }); + return pages; + }, []); }; type Listener = (pages: RecentPage[]) => void; @@ -124,9 +212,16 @@ class RecentPagesStore { return this.loadPromise; } - record(entry: Pick): void { + record(entry: RecentPageInput): void { + const previous = this.pages.find((page) => page.path === entry.path); + const nextEntry: RecentPage = { + ...entry, + title: entry.entity ? entry.title : previous?.title ?? entry.title, + entity: entry.entity ?? previous?.entity, + visitedAt: Date.now(), + }; const next: RecentPage[] = [ - { ...entry, visitedAt: Date.now() }, + nextEntry, ...this.pages.filter((page) => page.path !== entry.path), ].slice(0, RECENT_PAGES_LIMIT); if ( @@ -202,22 +297,81 @@ export const useRecentPages = (): RecentPage[] => { // Records a user-profile visit only after the profile has been resolved // successfully, so we never write 404 paths or other top-level slugs that // happen to match the /[userId] catch-all. +// +// We intentionally use the canonical entity URL (e.g. `/{username}`) instead +// of `router.asPath`. Opening a post modal on top of an entity page swaps the +// address-bar URL to `/posts/{id}` via shallow routing, and reading +// `router.asPath` here would otherwise persist the post URL under the +// entity's name and icon. export const useRecordRecentUserVisit = ( - user: { username?: string | null } | null | undefined, + user: + | (Pick & { + username?: string | null; + }) + | null + | undefined, ): void => { - const router = useRouter(); - useEffect(() => { if (!user?.username) { return; } recentPagesStore.load(); recentPagesStore.record({ - path: canonicalize(router.asPath), - title: `@${user.username}`, + path: `/${user.username}`, + title: user.name || `@${user.username}`, kind: 'user', + entity: { + type: 'user', + username: user.username, + image: user.image, + name: user.name, + }, + }); + }, [user?.image, user?.name, user?.username]); +}; + +export const useRecordRecentSquadVisit = ( + squad: Pick | null | undefined, +): void => { + useEffect(() => { + if (!squad?.handle) { + return; + } + recentPagesStore.load(); + recentPagesStore.record({ + path: `/squads/${squad.handle}`, + title: squad.name || `s/${squad.handle}`, + kind: 'squad', + entity: { + type: 'squad', + handle: squad.handle, + image: squad.image, + name: squad.name, + }, + }); + }, [squad?.handle, squad?.image, squad?.name]); +}; + +export const useRecordRecentSourceVisit = ( + source: Pick | null | undefined, +): void => { + useEffect(() => { + if (!source?.handle) { + return; + } + recentPagesStore.load(); + recentPagesStore.record({ + path: `/sources/${source.handle}`, + title: source.name || source.handle, + kind: 'source', + entity: { + type: 'source', + handle: source.handle, + image: source.image, + name: source.name, + }, }); - }, [router.asPath, user?.username]); + }, [source?.handle, source?.image, source?.name]); }; export const clearRecentPages = (): void => { diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index dd735255ed..3e016d68ba 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -66,6 +66,7 @@ import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; import { ArchiveEntryCard } from '@dailydotdev/shared/src/components/archive/ArchiveEntryCard'; import { ArchiveBreadcrumbs } from '@dailydotdev/shared/src/components/archive/ArchiveBreadcrumbs'; import { ArchiveScopeType } from '@dailydotdev/shared/src/graphql/archive'; +import { useRecordRecentSourceVisit } from '@dailydotdev/shared/src/hooks/feed/useRecentPages'; import Custom404 from '../404'; import { defaultOpenGraph, defaultSeo } from '../../next-seo'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; @@ -220,6 +221,7 @@ const SourcePage = ({ const { shouldShowAuthBanner } = useOnboardingActions(); const shouldShowTagSourceSocialProof = shouldShowAuthBanner && isLaptop; const { user } = useContext(AuthContext); + useRecordRecentSourceVisit(source); const mostUpvotedQueryVariables = useMemo( () => ({ source: source?.id, From acbab323935b253a040e03c3b7876c5cd1b4375a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 13 May 2026 10:31:04 +0300 Subject: [PATCH 30/34] feat(layout): extend PageHeader to more sidebar destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roll the unified page header out to the remaining feed-like surfaces reachable from the dual desktop sidebar: - Find Squads (`/squads/discover`): laptop now renders the category navbar AND the "New Squad" button inside the slim PageHeader strip instead of a tall standalone header. Mobile keeps its existing stacked layout. - Source feed pages (`/sources/[source]`): the source name now lives in a PageHeader above the identity card, matching the squad page. - Briefing (`/briefing`): laptop replaces the bespoke top header with the unified PageHeader (title + generate/settings actions). Mobile keeps the legacy back-button header. To support custom internal layouts (e.g. the find-squads tabs row), expose the `pageHeaderClassName` constant so callers can compose their own `
    ` shell without duplicating styling. The briefing page is added to the strict-typecheck skip list — it carries pre-existing strict violations (FeedAdTemplate null, PostModalMap undefined index, useFeed/format overloads) that pre-date this branch and should be cleaned up separately. Co-authored-by: Cursor --- .../src/components/layout/PageHeader.tsx | 14 +-- .../squads/layout/SquadDirectoryLayout.tsx | 85 ++++++++++++------- packages/webapp/pages/briefing/index.tsx | 30 ++++++- packages/webapp/pages/sources/[source].tsx | 2 + scripts/typecheck-strict-changed.js | 6 ++ 5 files changed, 97 insertions(+), 40 deletions(-) diff --git a/packages/shared/src/components/layout/PageHeader.tsx b/packages/shared/src/components/layout/PageHeader.tsx index dc4ba71fa7..f3a2c36d3f 100644 --- a/packages/shared/src/components/layout/PageHeader.tsx +++ b/packages/shared/src/components/layout/PageHeader.tsx @@ -2,6 +2,13 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; +// Visual shell of the page-header strip. Exported so callers that +// need a custom internal layout (e.g. wide horizontal tabs that +// shouldn't be locked inside the title/actions slot) can compose +// their own `
    ` without duplicating the styling. +export const pageHeaderClassName = + 'flex w-full items-center gap-2 border-b border-border-subtlest-quaternary px-6 py-3'; + export interface PageHeaderProps { /** * Left-side title. A plain string is wrapped in a bold callout @@ -29,12 +36,7 @@ export const PageHeader = ({ children, className, }: PageHeaderProps): ReactElement => ( -
    +
    {title !== undefined && (typeof title === 'string' ? ( diff --git a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx index 55ddcc99eb..1073a570f5 100644 --- a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx +++ b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx @@ -14,6 +14,7 @@ import { import { PlusIcon } from '../../icons'; import { useSquadDirectoryLayout } from './useSquadDirectoryLayout'; import { squadCategoriesPaths } from '../../../lib/constants'; +import { pageHeaderClassName } from '../../layout/PageHeader'; type SquadDirectoryLayoutProps = PropsWithChildren & ComponentProps<'section'>; @@ -57,41 +58,61 @@ export const SquadDirectoryLayout = ( const isDiscover = pathname === squadCategoriesPaths.discover; + const tabItems = Object.entries(categoryPaths ?? {}).map( + ([category, path]) => ( + + ), + ); + return ( - - {isDiscover && ( -
    - )} + <> + {/* Laptop: unified page header strip with tabs + New Squad action. + * The navbar's mobile bleed/border classes are overridden so it + * lives flush inside the slim header row. */} +
    + + {tabItems} + +
    + +
    +
    + + {isDiscover && ( +
    + )} -
    -
    - Squads - } variant={ButtonVariant.Primary} /> -
    -
    +
    +
    + Squads + } + variant={ButtonVariant.Primary} + /> +
    - {Object.entries(categoryPaths ?? {}).map(([category, path]) => ( - - ))} + {tabItems} -
    - -
    -
    -
    -
    - {children} -
    - +
    +
    + {children} +
    + + ); }; diff --git a/packages/webapp/pages/briefing/index.tsx b/packages/webapp/pages/briefing/index.tsx index 9d1849dd89..9080966475 100644 --- a/packages/webapp/pages/briefing/index.tsx +++ b/packages/webapp/pages/briefing/index.tsx @@ -6,6 +6,7 @@ import { Typography, TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { BriefListItem } from '@dailydotdev/shared/src/components/brief/BriefListItem'; import { BriefListHeading } from '@dailydotdev/shared/src/components/brief/BriefListHeading'; import { BriefListSection } from '@dailydotdev/shared/src/components/brief/BriefListSection'; @@ -154,10 +155,11 @@ const Page = (): ReactElement => {
    -
    + {/* Mobile keeps the legacy stacked header with back button. + * Laptop uses the unified slim PageHeader strip. */} +
    )}
    +
    + + {isNotPlus && !emptyFeed && !hasTodayBrief && ( + + )} +
    {isNotPlus && !!firstBrief && diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index 3e016d68ba..82fa55e504 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -67,6 +67,7 @@ import { ArchiveEntryCard } from '@dailydotdev/shared/src/components/archive/Arc import { ArchiveBreadcrumbs } from '@dailydotdev/shared/src/components/archive/ArchiveBreadcrumbs'; import { ArchiveScopeType } from '@dailydotdev/shared/src/graphql/archive'; import { useRecordRecentSourceVisit } from '@dailydotdev/shared/src/hooks/feed/useRecentPages'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import Custom404 from '../404'; import { defaultOpenGraph, defaultSeo } from '../../next-seo'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; @@ -267,6 +268,7 @@ const SourcePage = ({ dangerouslySetInnerHTML={{ __html: jsonLd }} /> + Date: Wed, 13 May 2026 10:35:35 +0300 Subject: [PATCH 31/34] fix(sidebar): daily.dev logo + snug tooltips on the rail top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bitmap `sidebar-app-icon.png` in the top-left rail slot with the official `LogoIcon` SVG so the brand mark sharpens at any DPR and follows the theme color token. The shared Tooltip primitive bakes in `collisionPadding={{ top: 75 }}` — a leftover from the legacy global-header layout. On the dual sidebar laptop layout there's no top chrome, so that default shoves the Home/Search/expand-collapse tooltips downward, leaving them visually misaligned and clipped. Override with a snug 4px padding on those three triggers so the tooltip vertically centers with the icon it labels. Co-authored-by: Cursor --- .../src/components/sidebar/SidebarDesktop.tsx | 50 ++++++++++++------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index c447e26ee1..6886dd3821 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -66,6 +66,7 @@ import Link from '../utilities/Link'; import { settingsUrl, webappUrl } from '../../lib/constants'; import { FeedbackWidget } from '../feedback'; import { isAppleDevice } from '../../lib/func'; +import LogoIcon from '../../svg/LogoIcon'; import InteractivePopup, { InteractivePopupPosition, } from '../tooltips/InteractivePopup'; @@ -77,7 +78,6 @@ import { QuestButton } from '../quest/QuestButton'; import { AchievementTrackerPanel } from '../filters/AchievementTrackerButton'; import { Typography, TypographyType } from '../typography/Typography'; import { useRecentPagesTracker } from '../../hooks/feed/useRecentPages'; -import { fromCDN } from '../../lib/links'; type SidebarCategoryConfig = { id: SidebarSelectedCategory; @@ -219,6 +219,13 @@ const RAIL_HOVER_OPEN_DELAY = 250; 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 old global-header layout). On the dual-sidebar +// laptop layout there's no top chrome, so that default shoves tooltips +// for icons near the viewport top (Home, Search, expand/collapse) +// downward — they read as misaligned and visually clipped. A snug +// override re-centers them with the trigger. +const RAIL_TOOLTIP_COLLISION_PADDING = 4; interface RailHoverCardProps { label: string; @@ -271,7 +278,11 @@ const RailHoverCard = ({ const themeIconMap: Record< ThemeMode, - React.ComponentType<{ secondary?: boolean; size?: IconSize; 'aria-hidden'?: boolean }> + React.ComponentType<{ + secondary?: boolean; + size?: IconSize; + 'aria-hidden'?: boolean; + }> > = { [ThemeMode.Dark]: MoonIcon, [ThemeMode.Light]: SunIcon, @@ -300,13 +311,7 @@ const SidebarThemeButton = (): ReactElement => { const isActive = theme.value === themeMode; return { label: theme.label, - icon: ( - - ), + icon: , action: () => onSelectTheme(theme.value), }; }); @@ -649,27 +654,30 @@ export const SidebarDesktop = ({ aria-label="Primary navigation" className="flex h-dvh min-h-dvh w-16 shrink-0 flex-col items-center gap-1 px-3 pb-3 pt-6" > - + - + -
    -
    -
    - -
    -
    + +
    +
    + + {title} + + + {subtitle} +
    +
    +
    ); }; diff --git a/packages/shared/src/components/cards/brief/BriefShortcutButton.tsx b/packages/shared/src/components/cards/brief/BriefShortcutButton.tsx new file mode 100644 index 0000000000..d103a125c2 --- /dev/null +++ b/packages/shared/src/components/cards/brief/BriefShortcutButton.tsx @@ -0,0 +1,75 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { BriefIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { useActions } from '../../../hooks'; +import { ActionType } from '../../../graphql/actions'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetId } from '../../../lib/log'; +import { webappUrl } from '../../../lib/constants'; + +type BriefShortcutButtonProps = { + className?: string; +}; + +export const BriefShortcutButton = ({ + className, +}: BriefShortcutButtonProps): ReactElement | null => { + const { isLoggedIn, isAuthReady, user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { isActionsFetched, checkHasCompleted } = useActions(); + const impressionRef = useRef(false); + + const hasGeneratedPreviously = + isActionsFetched && checkHasCompleted(ActionType.GeneratedBrief); + const targetHref = `${webappUrl}${ + hasGeneratedPreviously ? 'briefing/generate' : 'briefing?generate=true' + }`; + + useEffect(() => { + if (impressionRef.current || !isAuthReady || !isLoggedIn) { + return; + } + + impressionRef.current = true; + logEvent({ + event_name: LogEvent.ImpressionBrief, + target_id: TargetId.Header, + extra: JSON.stringify({ + is_demo: !user?.isPlus, + brief_date: new Date(), + }), + }); + }, [isAuthReady, isLoggedIn, logEvent, user?.isPlus]); + + const onClick = useCallback(() => { + logEvent({ + event_name: LogEvent.ClickBrief, + target_id: TargetId.Header, + extra: JSON.stringify({ + is_demo: !user?.isPlus, + brief_date: new Date(), + }), + }); + }, [logEvent, user?.isPlus]); + + if (!isLoggedIn) { + return null; + } + + return ( + + ); +}; diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx index ec1b09479e..c02d5110e4 100644 --- a/packages/shared/src/components/feedback/FeedbackWidget.tsx +++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx @@ -94,8 +94,7 @@ export function FeedbackWidget({ // dismissing it from the sidebar. const isSupport = placement === 'support'; const isSidebar = placement === 'sidebar'; - const isVisible = - !!user && !isMobile && (isSupport || showFeedbackButton); + const isVisible = !!user && !isMobile && (isSupport || showFeedbackButton); useEffect(() => { if (!isVisible) { diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index 1cc18306c1..a118fed849 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -44,7 +44,6 @@ export interface FeedContainerProps { actionButtons?: ReactNode; isHorizontal?: boolean; feedContainerRef?: React.Ref; - showBriefCard?: boolean; disableListFrame?: boolean; } @@ -130,7 +129,6 @@ export const FeedContainer = ({ actionButtons, isHorizontal, feedContainerRef, - showBriefCard, disableListFrame = false, }: FeedContainerProps): ReactElement => { const currentSettings = useContext(FeedContext); @@ -220,18 +218,10 @@ export const FeedContainer = ({ )} > {shouldShowBanner && ( -
    +
    ` without duplicating the styling. +// +// `min-h-14` (56px = a Small button + py-3 on each side) locks the +// strip to a consistent height across pages so a header with action +// buttons (Profile's Save, API's Create token, ...) is the same +// height as a header that only renders a title (Content sources, +// Tags, ...). Without this, navigating between settings pages +// caused a 12px vertical shift of the page content below. export const pageHeaderClassName = - 'flex w-full items-center gap-2 border-b border-border-subtlest-quaternary px-6 py-3'; + 'flex min-h-14 w-full items-center gap-2 border-b border-border-subtlest-quaternary px-6 py-3'; export interface PageHeaderProps { /** diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index fd9aa59978..ab6528e89c 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -33,6 +33,7 @@ import { QueryStateKeys, useQueryState } from '../../hooks/utils/useQueryState'; import type { AllowedTags, TypographyProps } from '../typography/Typography'; import { Typography } from '../typography/Typography'; import { ToggleClickbaitShield } from '../buttons/ToggleClickbaitShield'; +import { BriefShortcutButton } from '../cards/brief/BriefShortcutButton'; import { LogEvent, Origin } from '../../lib/log'; import { ActionType } from '../../graphql/actions'; import { @@ -183,6 +184,10 @@ export const SearchControlHeader = ({ } /> ), + , isUpvoted ? ( { - return ( -
    - - Jobs - -
    - - - - -
    -
    - ); -}; diff --git a/packages/shared/src/components/squads/SquadPageHeader.tsx b/packages/shared/src/components/squads/SquadPageHeader.tsx index f9eb68882a..a8176c1149 100644 --- a/packages/shared/src/components/squads/SquadPageHeader.tsx +++ b/packages/shared/src/components/squads/SquadPageHeader.tsx @@ -37,6 +37,10 @@ interface SquadPageHeaderProps { squad: Squad; members: BasicSourceMember[]; shouldUseListMode: boolean; + // When the unified PageHeader at the top of the floating card already + // hosts the action bar (laptop), hide the in-card duplicate so the + // squad identity card stays focused on identity + sharing. + hideHeaderBar?: boolean; } const MAX_WIDTH = 'laptopL:max-w-[38.5rem]'; @@ -46,6 +50,7 @@ export function SquadPageHeader({ squad, members, shouldUseListMode, + hideHeaderBar = false, }: SquadPageHeaderProps): ReactElement { const { openModal } = useLazyModal(); const allowedToPost = verifyPermission(squad, SourcePermissions.Post); @@ -154,7 +159,9 @@ export function SquadPageHeader({ {squad.description} )} - + {!hideHeaderBar && ( + + )} { const isMobile = useViewSize(ViewSize.MobileL); + const isLaptop = useViewSize(ViewSize.Laptop); const [, setIsOpen] = useQueryState({ key: navigationKey, defaultValue: false, }); + // Capture the laptop PageHeader's portal targets imperatively after + // SettingsLayout has committed. Reading the DOM in a useEffect (rather + // than via `ref={setState}`) avoids running setState from a ref-detach + // during the React commit phase, which previously cascaded into the + // "Maximum update depth exceeded" error on settings pages. + const [titleNode, setTitleNode] = useState(null); + const [actionsNode, setActionsNode] = useState(null); + useEffect(() => { + if (!isLaptop) { + setTitleNode(null); + setActionsNode(null); + return; + } + setTitleNode(document.getElementById(SETTINGS_HEADER_TITLE_PORTAL_ID)); + setActionsNode(document.getElementById(SETTINGS_HEADER_ACTIONS_PORTAL_ID)); + }, [isLaptop]); - return ( - - + const portaledTitle = ( + <> + · + {title} + + ); + + const portaledActions = ( + <> + {onBack && ( diff --git a/packages/webapp/components/layouts/SettingsLayout/index.tsx b/packages/webapp/components/layouts/SettingsLayout/index.tsx index 614ef53461..8f5e731509 100644 --- a/packages/webapp/components/layouts/SettingsLayout/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/index.tsx @@ -24,15 +24,28 @@ import { ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { BuyCreditsButton } from '@dailydotdev/shared/src/components/credit/BuyCreditsButton'; import { useCanPurchaseCores } from '@dailydotdev/shared/src/hooks/useCoresFeature'; import { getPathnameWithQuery } from '@dailydotdev/shared/src/lib'; import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { getLayout as getFooterNavBarLayout } from '../FooterNavBarLayout'; import { getLayout as getMainLayout } from '../MainLayout'; +// Stable DOM ids for the per-page title / actions portal targets that +// `AccountPageContainer` writes into via `createPortal`. Using fixed +// ids (queried with `document.getElementById` from the consumer) avoids +// having to expose live DOM nodes via React state — the previous +// "ref={setState}" approach fired a state update during ref detach in +// the commit phase, which on settings pages cascaded into React's +// "Maximum update depth exceeded" guard. +export const SETTINGS_HEADER_TITLE_PORTAL_ID = 'settings-header-title-portal'; +export const SETTINGS_HEADER_ACTIONS_PORTAL_ID = + 'settings-header-actions-portal'; + const ProfileSettingsMenuMobile = dynamic( () => import( @@ -59,6 +72,8 @@ export const navigationKey = generateQueryKey( undefined, ); +const SETTINGS_BASE_TITLE = 'Settings'; + export default function SettingsLayout({ children, }: PropsWithChildren): ReactElement | null { @@ -120,6 +135,25 @@ export default function SettingsLayout({ ); } + // Flat title structure on purpose: the "Settings" base label and the + // per-page portal target are direct text/element children of the same + // h1 so the portal can inject text nodes inline without wrestling + // with `display: contents` inside a `truncate` wrapper (which was + // causing portaled content to silently not render in some hydration + // orders, leaving the header stuck at "Settings" only). + const laptopHeaderTitle = ( + + {SETTINGS_BASE_TITLE} + + + ); + return ( <> {!isMobile && !isLaptop && ( @@ -153,13 +187,21 @@ export default function SettingsLayout({ />
    )} + {isLaptop && ( + + + + )} {router.query.redirectTo && router.query.redirectCopy && ( + )} +
    + +
    + + {isNotPlus && !emptyFeed && !hasTodayBrief && ( + + )} +
    - {/* Mobile keeps the legacy stacked header with back button. - * Laptop uses the unified slim PageHeader strip. */} -
    - - - )} -
    - -
    - - {isNotPlus && !emptyFeed && !hasTodayBrief && ( - - )} -
    {isNotPlus && !!firstBrief && diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 38b18d3338..43dab39c25 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -703,19 +703,8 @@ function GameCenterPage({ return ( +
    - - Game Center - - } - />
    diff --git a/packages/webapp/pages/jobs/index.tsx b/packages/webapp/pages/jobs/index.tsx index 0fa4cf61cd..f63b12d188 100644 --- a/packages/webapp/pages/jobs/index.tsx +++ b/packages/webapp/pages/jobs/index.tsx @@ -1,7 +1,15 @@ import type { ReactElement } from 'react'; import React, { useEffect, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { OpportunityHeader } from '@dailydotdev/shared/src/components/opportunity/OpportunityHeader'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { FilterIcon } from '@dailydotdev/shared/src/components/icons'; +import { settingsUrl, webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { useActions } from '@dailydotdev/shared/src/hooks'; import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; import { getUserOpportunityMatchesOptions } from '@dailydotdev/shared/src/features/opportunity/queries'; @@ -26,6 +34,25 @@ const activeStatuses = [ OpportunityMatchStatus.CandidateAccepted, ]; +const JobsPageHeader = (): ReactElement => ( + + + + + + + ); + })} + + ); if (isLoadingPreferences) { - return
    ; + return ( + +
    + + ); } return ( - - - - - - - - - - + + {activeTab === 'in-app' ? ( + + ) : ( + + )} + ); }; diff --git a/packages/webapp/pages/settings/profile/experience/edit.tsx b/packages/webapp/pages/settings/profile/experience/edit.tsx index d1acb8892d..3c3595ba4c 100644 --- a/packages/webapp/pages/settings/profile/experience/edit.tsx +++ b/packages/webapp/pages/settings/profile/experience/edit.tsx @@ -198,12 +198,13 @@ const Page = ({ experience }: PageProps): ReactElement => { }`} actions={ diff --git a/packages/webapp/pages/sources/index.tsx b/packages/webapp/pages/sources/index.tsx index fcea385edc..ddce9faf14 100644 --- a/packages/webapp/pages/sources/index.tsx +++ b/packages/webapp/pages/sources/index.tsx @@ -6,19 +6,19 @@ import type { NextSeoProps } from 'next-seo/lib/types'; import { Button, + ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; -import { PlusIcon, SitesIcon } from '@dailydotdev/shared/src/components/icons'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons'; import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; import type { Source } from '@dailydotdev/shared/src/graphql/sources'; import { SOURCE_DIRECTORY_QUERY } from '@dailydotdev/shared/src/graphql/sources'; -import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { useRouter } from 'next/router'; -import { BreadCrumbs } from '@dailydotdev/shared/src/components/header/BreadCrumbs'; import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { PageWrapperLayout } from '@dailydotdev/shared/src/components/layout/PageWrapperLayout'; import { SourceTopList } from '@dailydotdev/shared/src/components/cards/Leaderboard'; import { getLayout } from '../../components/layouts/MainLayout'; @@ -94,51 +94,50 @@ const SourcesPage = ({ ).slice(0, 100); return ( - - -